Add: just commands for release build

This commit is contained in:
DaZuo0122
2026-02-13 11:22:46 +08:00
parent 3c3ca342c9
commit 55fe53235d
21 changed files with 253 additions and 97 deletions

View File

@@ -26,6 +26,7 @@ use tracing::{error, info, warn};
const APP_NAME: &str = "sprimo"; const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default"; const DEFAULT_PACK: &str = "default";
const WINDOW_PADDING: f32 = 16.0; const WINDOW_PADDING: f32 = 16.0;
const STARTUP_WINDOW_SIZE: f32 = 416.0;
const MAGENTA_KEY: [u8; 3] = [255, 0, 255]; const MAGENTA_KEY: [u8; 3] = [255, 0, 255];
const CHROMA_KEY_TOLERANCE: u8 = 24; const CHROMA_KEY_TOLERANCE: u8 = 24;
const CHROMA_KEY_FORCE_RATIO: f32 = 0.15; const CHROMA_KEY_FORCE_RATIO: f32 = 0.15;
@@ -180,7 +181,13 @@ fn main() -> Result<(), AppError> {
.compact() .compact()
.init(); .init();
let (config_path, config) = sprimo_config::load_or_create(APP_NAME)?; let (config_path, mut config) = sprimo_config::load_or_create(APP_NAME)?;
if config.window.click_through {
config.window.click_through = false;
if let Err(err) = save(&config_path, &config) {
warn!(%err, "failed to persist click-through disable at startup");
}
}
let platform: Arc<dyn PlatformAdapter> = create_adapter().into(); let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
let capabilities = platform.capabilities(); let capabilities = platform.capabilities();
@@ -188,7 +195,7 @@ fn main() -> Result<(), AppError> {
snapshot.x = config.window.x; snapshot.x = config.window.x;
snapshot.y = config.window.y; snapshot.y = config.window.y;
snapshot.scale = config.window.scale; snapshot.scale = config.window.scale;
snapshot.flags.click_through = config.window.click_through; snapshot.flags.click_through = false;
snapshot.flags.always_on_top = config.window.always_on_top; snapshot.flags.always_on_top = config.window.always_on_top;
snapshot.flags.visible = config.window.visible; snapshot.flags.visible = config.window.visible;
snapshot.active_sprite_pack = config.sprite.selected_pack.clone(); snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
@@ -223,7 +230,10 @@ fn main() -> Result<(), AppError> {
decorations: false, decorations: false,
resizable: false, resizable: false,
window_level: WindowLevel::AlwaysOnTop, window_level: WindowLevel::AlwaysOnTop,
resolution: bevy::window::WindowResolution::new(544.0, 544.0), resolution: bevy::window::WindowResolution::new(
STARTUP_WINDOW_SIZE,
STARTUP_WINDOW_SIZE,
),
position: WindowPosition::At(IVec2::new( position: WindowPosition::At(IVec2::new(
config.window.x.round() as i32, config.window.x.round() as i32,
config.window.y.round() as i32, config.window.y.round() as i32,
@@ -721,7 +731,6 @@ fn attach_window_handle_once(
.read() .read()
.expect("frontend snapshot lock poisoned"); .expect("frontend snapshot lock poisoned");
let _ = platform.0.set_always_on_top(guard.flags.always_on_top); let _ = platform.0.set_always_on_top(guard.flags.always_on_top);
let _ = platform.0.set_click_through(guard.flags.click_through);
let _ = platform.0.set_visible(guard.flags.visible); let _ = platform.0.set_visible(guard.flags.visible);
let _ = platform.0.set_window_position(guard.x, guard.y); let _ = platform.0.set_window_position(guard.x, guard.y);
*attached = true; *attached = true;
@@ -746,14 +755,14 @@ fn poll_hotkey_recovery(
) { ) {
while ingress.0.try_recv().is_ok() { while ingress.0.try_recv().is_ok() {
info!("recovery hotkey received"); info!("recovery hotkey received");
let _ = platform.0.set_click_through(false); let _ = platform.0.set_always_on_top(true);
let _ = platform.0.set_visible(true); let _ = platform.0.set_visible(true);
if let Ok(mut visibility) = pet_query.get_single_mut() { if let Ok(mut visibility) = pet_query.get_single_mut() {
*visibility = Visibility::Visible; *visibility = Visibility::Visible;
} }
config.config.window.click_through = false; config.config.window.always_on_top = true;
config.config.window.visible = true; config.config.window.visible = true;
if let Err(err) = save(&config.path, &config.config) { if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist config after hotkey recovery"); warn!(%err, "failed to persist config after hotkey recovery");
@@ -761,6 +770,7 @@ fn poll_hotkey_recovery(
if let Ok(mut guard) = snapshot.0.write() { if let Ok(mut guard) = snapshot.0.write() {
guard.flags.click_through = false; guard.flags.click_through = false;
guard.flags.always_on_top = true;
guard.flags.visible = true; guard.flags.visible = true;
guard.last_error = None; guard.last_error = None;
} }
@@ -927,9 +937,8 @@ fn poll_backend_commands(
always_on_top, always_on_top,
visible, visible,
} => { } => {
if let Some(value) = click_through { if click_through.is_some() {
let _ = platform.0.set_click_through(value); config.config.window.click_through = false;
config.config.window.click_through = value;
} }
if let Some(value) = always_on_top { if let Some(value) = always_on_top {
let _ = platform.0.set_always_on_top(value); let _ = platform.0.set_always_on_top(value);
@@ -949,9 +958,7 @@ fn poll_backend_commands(
warn!(%err, "failed to persist flag config"); warn!(%err, "failed to persist flag config");
} }
if let Ok(mut guard) = snapshot.0.write() { if let Ok(mut guard) = snapshot.0.write() {
if let Some(value) = click_through { guard.flags.click_through = false;
guard.flags.click_through = value;
}
if let Some(value) = always_on_top { if let Some(value) = always_on_top {
guard.flags.always_on_top = value; guard.flags.always_on_top = value;
} }

View File

@@ -171,7 +171,7 @@ mod windows {
impl PlatformAdapter for WindowsAdapter { impl PlatformAdapter for WindowsAdapter {
fn capabilities(&self) -> CapabilityFlags { fn capabilities(&self) -> CapabilityFlags {
CapabilityFlags { CapabilityFlags {
supports_click_through: true, supports_click_through: false,
supports_transparency: true, supports_transparency: true,
supports_tray: false, supports_tray: false,
supports_global_hotkey: true, supports_global_hotkey: true,

View File

@@ -35,14 +35,16 @@ impl RuntimeCore {
pub fn new_with_config( pub fn new_with_config(
config_path: PathBuf, config_path: PathBuf,
config_value: AppConfig, mut config_value: AppConfig,
capabilities: CapabilityFlags, capabilities: CapabilityFlags,
) -> Result<Self, RuntimeCoreError> { ) -> Result<Self, RuntimeCoreError> {
let click_through_was_enabled = config_value.window.click_through;
config_value.window.click_through = false;
let mut snapshot = FrontendStateSnapshot::idle(capabilities); let mut snapshot = FrontendStateSnapshot::idle(capabilities);
snapshot.x = config_value.window.x; snapshot.x = config_value.window.x;
snapshot.y = config_value.window.y; snapshot.y = config_value.window.y;
snapshot.scale = config_value.window.scale; snapshot.scale = config_value.window.scale;
snapshot.flags.click_through = config_value.window.click_through; snapshot.flags.click_through = false;
snapshot.flags.always_on_top = config_value.window.always_on_top; snapshot.flags.always_on_top = config_value.window.always_on_top;
snapshot.flags.visible = config_value.window.visible; snapshot.flags.visible = config_value.window.visible;
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone(); snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
@@ -50,14 +52,18 @@ impl RuntimeCore {
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone()); let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone());
let (command_tx, command_rx) = mpsc::channel(1_024); let (command_tx, command_rx) = mpsc::channel(1_024);
Ok(Self { let core = Self {
config_path, config_path,
config: Arc::new(RwLock::new(config_value)), config: Arc::new(RwLock::new(config_value)),
snapshot: Arc::new(RwLock::new(snapshot)), snapshot: Arc::new(RwLock::new(snapshot)),
api_config, api_config,
command_tx, command_tx,
command_rx: Arc::new(Mutex::new(command_rx)), command_rx: Arc::new(Mutex::new(command_rx)),
}) };
if click_through_was_enabled {
core.persist_config()?;
}
Ok(core)
} }
pub fn snapshot(&self) -> Arc<RwLock<FrontendStateSnapshot>> { pub fn snapshot(&self) -> Arc<RwLock<FrontendStateSnapshot>> {
@@ -169,7 +175,7 @@ impl RuntimeCore {
self.persist_config()?; self.persist_config()?;
} }
FrontendCommand::SetFlags { FrontendCommand::SetFlags {
click_through, click_through: _click_through,
always_on_top, always_on_top,
visible, visible,
} => { } => {
@@ -178,9 +184,7 @@ impl RuntimeCore {
.snapshot .snapshot
.write() .write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
if let Some(value) = click_through { snapshot.flags.click_through = false;
snapshot.flags.click_through = *value;
}
if let Some(value) = always_on_top { if let Some(value) = always_on_top {
snapshot.flags.always_on_top = *value; snapshot.flags.always_on_top = *value;
} }
@@ -194,9 +198,7 @@ impl RuntimeCore {
.config .config
.write() .write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?; .map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if let Some(value) = click_through { config.window.click_through = false;
config.window.click_through = *value;
}
if let Some(value) = always_on_top { if let Some(value) = always_on_top {
config.window.always_on_top = *value; config.window.always_on_top = *value;
} }
@@ -259,4 +261,26 @@ mod tests {
assert_eq!(snapshot.state, FrontendState::Active); assert_eq!(snapshot.state, FrontendState::Active);
assert_eq!(snapshot.current_animation, "active"); assert_eq!(snapshot.current_animation, "active");
} }
#[test]
fn click_through_flag_is_ignored_and_forced_false() {
let temp = TempDir::new().expect("tempdir");
let path = temp.path().join("config.toml");
let mut config = AppConfig::default();
config.window.click_through = true;
let core = RuntimeCore::new_with_config(path, config, CapabilityFlags::default())
.expect("core init");
core.apply_command(&FrontendCommand::SetFlags {
click_through: Some(true),
always_on_top: None,
visible: None,
})
.expect("apply");
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
assert!(!snapshot.flags.click_through);
let config = core.config().read().expect("config lock").clone();
assert!(!config.window.click_through);
}
} }

View File

@@ -5,6 +5,7 @@
"windows": ["*"], "windows": ["*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-start-dragging",
"core:event:allow-listen", "core:event:allow-listen",
"core:event:allow-unlisten" "core:event:allow-unlisten"
] ]

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:event:allow-listen","core:event:allow-unlisten"]}} {"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:event:allow-listen","core:event:allow-unlisten"]}}

View File

@@ -153,6 +153,8 @@ fn main() -> Result<(), AppError> {
} }
let command_rx = runtime_core.command_receiver(); let command_rx = runtime_core.command_receiver();
let runtime_core_for_commands = Arc::clone(&runtime_core);
let app_handle_for_commands = app_handle.clone();
runtime.spawn(async move { runtime.spawn(async move {
loop { loop {
let next = { let next = {
@@ -163,24 +165,45 @@ fn main() -> Result<(), AppError> {
break; break;
}; };
if let Err(err) = runtime_core.apply_command(&envelope.command) { if let Err(err) = runtime_core_for_commands.apply_command(&envelope.command) {
warn!(%err, "failed to apply command in tauri runtime"); warn!(%err, "failed to apply command in tauri runtime");
continue; continue;
} }
let payload = { let payload = {
let snapshot = runtime_core.snapshot(); let snapshot = runtime_core_for_commands.snapshot();
match snapshot.read() { match snapshot.read() {
Ok(s) => Some(to_ui_snapshot(&s)), Ok(s) => Some(to_ui_snapshot(&s)),
Err(_) => None, Err(_) => None,
} }
}; };
if let Some(value) = payload { if let Some(value) = payload {
let _ = app_handle.emit("runtime:snapshot", value); let _ = app_handle_for_commands.emit("runtime:snapshot", value);
} }
} }
}); });
if let Some(window) = app.get_webview_window("main") {
let runtime_core = Arc::clone(&runtime_core);
let app_handle = app_handle.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::Moved(position) = event {
let command = sprimo_protocol::v1::FrontendCommand::SetTransform {
x: Some(position.x as f32),
y: Some(position.y as f32),
anchor: None,
scale: None,
opacity: None,
};
if runtime_core.apply_command(&command).is_ok() {
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot));
}
}
}
});
}
let _ = app; let _ = app;
Ok(()) Ok(())
}) })

View File

@@ -12,8 +12,8 @@
"windows": [ "windows": [
{ {
"title": "sprimo-tauri", "title": "sprimo-tauri",
"width": 640, "width": 416,
"height": 640, "height": 416,
"decorations": false, "decorations": false,
"transparent": true, "transparent": true,
"alwaysOnTop": true, "alwaysOnTop": true,

View File

@@ -18,9 +18,9 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
"uptime_seconds": 12, "uptime_seconds": 12,
"active_sprite_pack": "default", "active_sprite_pack": "default",
"capabilities": { "capabilities": {
"supports_click_through": true, "supports_click_through": false,
"supports_transparency": true, "supports_transparency": true,
"supports_tray": true, "supports_tray": false,
"supports_global_hotkey": true, "supports_global_hotkey": true,
"supports_skip_taskbar": true "supports_skip_taskbar": true
} }
@@ -70,6 +70,11 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
} }
``` ```
## Compatibility Notes
- `SetFlags.click_through` is deprecated for compatibility and ignored at runtime.
- Frontend state always reports `flags.click_through = false`.
## Error Response ## Error Response
```json ```json

View File

@@ -45,5 +45,6 @@ backend = "bevy"
- `auth_token` is generated on first run if config does not exist. - `auth_token` is generated on first run if config does not exist.
- `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands. - `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands.
- On Windows, `recovery_hotkey` provides click-through recovery even when the window is non-interactive. - `window.click_through` is deprecated and ignored at runtime; it is always forced to `false`.
- On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery.
- `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`). - `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`).

View File

@@ -10,7 +10,7 @@
# 1. Overview # 1. Overview
The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top** and optionally **click-through**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally. The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally.
The frontend must be able to run standalone (idle animation) even if the backend is not running. The frontend must be able to run standalone (idle animation) even if the backend is not running.
@@ -22,7 +22,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
1. **Render a cute animated character overlay** with smooth sprite animation. 1. **Render a cute animated character overlay** with smooth sprite animation.
2. Provide a **stable command interface** (REST) for backend control. 2. Provide a **stable command interface** (REST) for backend control.
3. Offer **essential user controls** (tray/menu + hotkeys optional) to avoid “locking” the pet in click-through mode. 3. Offer **essential user controls** (tray/menu + hotkeys optional) to keep the pet recoverable and visible.
4. Persist **window position, scale, sprite pack choice, and flags**. 4. Persist **window position, scale, sprite pack choice, and flags**.
5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland. 5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland.
@@ -43,7 +43,6 @@ The frontend must be able to run standalone (idle animation) even if the backend
* As a user, I can see the pet on my desktop immediately after launch. * As a user, I can see the pet on my desktop immediately after launch.
* As a user, I can drag the pet to a preferred location. * As a user, I can drag the pet to a preferred location.
* As a user, I can toggle click-through so the pet doesnt block my mouse.
* As a user, I can toggle always-on-top so the pet stays visible. * As a user, I can toggle always-on-top so the pet stays visible.
* As a user, I can change the character (sprite pack). * As a user, I can change the character (sprite pack).
@@ -55,7 +54,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
## Safety ## Safety
* As a user, I can always recover control of the pet even if click-through is enabled (hotkey/tray item). * As a user, I can always recover the pet visibility/interaction state via hotkey or tray item.
--- ---
@@ -82,22 +81,19 @@ The frontend must be able to run standalone (idle animation) even if the backend
* When ON, window stays above normal windows. * When ON, window stays above normal windows.
### FR-FW-3 Click-through (mouse pass-through) ### FR-FW-3 Interaction model
* Support enabling/disabling click-through: * Click-through is not required.
* Pet remains interactive while visible.
* ON: mouse events pass to windows underneath. * Must provide a **failsafe** mechanism to recover visibility and interaction state.
* OFF: pet receives mouse input (drag, context menu).
* Must provide a **failsafe** mechanism to disable click-through without clicking the pet.
**Acceptance** **Acceptance**
* With click-through enabled, user can click apps behind pet. * Recovery hotkey/tray action restores visible, interactive pet state reliably.
* User can disable click-through via tray or hotkey reliably.
### FR-FW-4 Dragging & anchoring ### FR-FW-4 Dragging & anchoring
* When click-through is OFF, user can drag the pet. * User can drag the pet.
* Dragging updates persisted position in config. * Dragging updates persisted position in config.
* Optional: snapping to screen edges. * Optional: snapping to screen edges.
@@ -130,12 +126,12 @@ The frontend must be able to run standalone (idle animation) even if the backend
### Platform notes (requirements) ### Platform notes (requirements)
* **Windows:** click-through uses extended window styles (WS_EX_TRANSPARENT / layered), always-on-top via SetWindowPos. * **Windows:** always-on-top via SetWindowPos.
* **macOS:** NSWindow level + ignoresMouseEvents. * **macOS:** NSWindow level + ignoresMouseEvents.
* **Linux:** best effort: * **Linux:** best effort:
* X11: possible with shape/input region. * X11: possible with shape/input region.
* Wayland: click-through may be unavailable; document limitation. * Wayland: overlay behavior limitations may apply; document limitation.
--- ---
@@ -273,7 +269,7 @@ Each state maps to a default animation (configurable by sprite pack):
* `PlayAnimation { name, priority, duration_ms?, interrupt? }` * `PlayAnimation { name, priority, duration_ms?, interrupt? }`
* `SetSpritePack { pack_id_or_path }` * `SetSpritePack { pack_id_or_path }`
* `SetTransform { x?, y?, anchor?, scale?, opacity? }` * `SetTransform { x?, y?, anchor?, scale?, opacity? }`
* `SetFlags { click_through?, always_on_top?, visible? }` * `SetFlags { click_through?, always_on_top?, visible? }` (`click_through` is deprecated/ignored)
* `Toast { text, ttl_ms? }` (optional but recommended) * `Toast { text, ttl_ms? }` (optional but recommended)
### FR-API-4 Idempotency & dedupe ### FR-API-4 Idempotency & dedupe
@@ -303,7 +299,6 @@ Each state maps to a default animation (configurable by sprite pack):
Provide tray/menu bar items: Provide tray/menu bar items:
* Show/Hide * Show/Hide
* Toggle Click-through
* Toggle Always-on-top * Toggle Always-on-top
* Sprite Pack selection (at least “Default” + “Open sprite folder…”) * Sprite Pack selection (at least “Default” + “Open sprite folder…”)
* Reload sprite packs * Reload sprite packs
@@ -315,7 +310,7 @@ If tray is too hard on Linux in v0.1, provide a fallback (hotkey + config).
At minimum one global hotkey: At minimum one global hotkey:
* Toggle click-through OR “enter interactive mode * Force visible + interactive recovery mode
Example default: Example default:
@@ -323,7 +318,7 @@ Example default:
**Acceptance** **Acceptance**
* User can recover control even if pet is click-through and cannot be clicked. * User can recover visibility and interaction state even when the pet was hidden or misplaced.
### FR-CTL-3 Context menu (optional) ### FR-CTL-3 Context menu (optional)
@@ -348,7 +343,7 @@ Right click pet (when interactive) to open a minimal menu.
* position (x,y) + monitor id (best-effort) * position (x,y) + monitor id (best-effort)
* scale * scale
* always_on_top * always_on_top
* click_through * click_through (deprecated/ignored; always false)
* visible * visible
* animation: * animation:
@@ -432,10 +427,10 @@ Frontend must expose in logs (and optionally `/v1/health`) capability flags:
Example: Example:
* Windows: all true * Windows: click-through false; others vary by implementation status
* macOS: all true * macOS: click-through false; others vary by implementation status
* Linux X11: most true * Linux X11: most true
* Linux Wayland: click-through likely false, skip-taskbar variable * Linux Wayland: skip-taskbar variable and overlay behavior limitations
--- ---
@@ -444,8 +439,8 @@ Example:
## Window ## Window
1. Launch: window appears borderless & transparent. 1. Launch: window appears borderless & transparent.
2. Drag: with click-through OFF, drag updates position; restart restores. 2. Drag: drag updates position; restart restores.
3. Click-through: toggle via hotkey; pet becomes non-interactive; toggle back works. 3. Recovery: hotkey restores visible + always-on-top behavior reliably.
4. Always-on-top: verify staying above typical apps. 4. Always-on-top: verify staying above typical apps.
## Animation ## Animation

View File

@@ -11,9 +11,9 @@ Date: 2026-02-12
| Command/state pipeline | Implemented | Command queue, state snapshot updates, transient state TTL rollback | | Command/state pipeline | Implemented | Command queue, state snapshot updates, transient state TTL rollback |
| Config persistence | Implemented | `config.toml` bootstrap/load/save with generated token | | Config persistence | Implemented | `config.toml` bootstrap/load/save with generated token |
| Sprite pack contract | Implemented | `manifest.json` loader and selected->default fallback | | Sprite pack contract | Implemented | `manifest.json` loader and selected->default fallback |
| Platform abstraction | Implemented | Windows adapter now applies click-through/top-most/visibility/position using Win32 APIs | | Platform abstraction | Implemented | Windows adapter applies top-most/visibility/position using Win32 APIs; click-through is disabled by current requirements |
| Overlay rendering | Implemented (MVP) | Bevy runtime with transparent undecorated window, sprite playback, command bridge | | Overlay rendering | Implemented (MVP) | Bevy runtime with transparent undecorated window, sprite playback, command bridge |
| Global failsafe | Implemented (Windows) | Global recovery hotkey `Ctrl+Alt+P` disables click-through and forces visibility | | Global failsafe | Implemented (Windows) | Global recovery hotkey `Ctrl+Alt+P` forces visibility and top-most recovery |
| Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) | | Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) |
| Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 | | Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 |
| 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` |

View File

@@ -14,7 +14,7 @@
- [x] `SetState` updates state and default animation mapping. - [x] `SetState` updates state and default animation mapping.
- [x] transient state with `ttl_ms` returns to durable state. - [x] transient state with `ttl_ms` returns to durable state.
- [x] `SetTransform` persists x/y/scale. - [x] `SetTransform` persists x/y/scale.
- [x] `SetFlags` persists click-through/always-on-top/visible. - [x] `SetFlags` persists always-on-top/visible and ignores deprecated click-through.
## Config ## Config

View File

@@ -4,7 +4,7 @@ Date: 2026-02-12
| Capability | Windows | Linux X11 | Linux Wayland | macOS | | Capability | Windows | Linux X11 | Linux Wayland | macOS |
|------------|---------|-----------|---------------|-------| |------------|---------|-----------|---------------|-------|
| `supports_click_through` | true (implemented) | false (current) | false | false (current) | | `supports_click_through` | false (disabled by product requirement) | false | false | false |
| `supports_transparency` | true | true | true | true | | `supports_transparency` | true | true | true | true |
| `supports_tray` | false (current) | false (current) | false (current) | false (current) | | `supports_tray` | false (current) | false (current) | false (current) | false (current) |
| `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) | | `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) |
@@ -12,6 +12,7 @@ Date: 2026-02-12
## Notes ## Notes
- Current code applies real Win32 operations for click-through, visibility, top-most, and positioning. - Current code applies real Win32 operations for visibility, top-most, and positioning.
- Click-through is intentionally disabled by current product requirements.
- Non-Windows targets currently use a no-op adapter with conservative flags. - Non-Windows targets currently use a no-op adapter with conservative flags.
- Wayland limitations remain an expected degradation in v0.1. - Wayland limitations remain an expected degradation in v0.1.

View File

@@ -23,13 +23,24 @@ Use `just` for command entry:
```powershell ```powershell
just check just check
just test just test
just build-release just build-release-bevy
just package-win just package-win-bevy
just smoke-win just smoke-win-bevy
just build-release-tauri
just package-win-tauri
just smoke-win-tauri
``` ```
`just package-win` calls `scripts/package_windows.py package`. Compatibility aliases:
`just smoke-win` calls `scripts/package_windows.py smoke`.
- `just build-release` -> Bevy release build.
- `just package-win` -> Bevy package.
- `just smoke-win` -> Bevy smoke package check.
Packaging script target selection:
- Bevy: `python scripts/package_windows.py package --frontend bevy`
- Tauri: `python scripts/package_windows.py package --frontend tauri`
## Behavior Test Checklist (Packaged App) ## Behavior Test Checklist (Packaged App)
@@ -37,8 +48,8 @@ Run tests from an unpacked ZIP folder, not from the workspace run.
1. Launch `sprimo-app.exe`; verify default sprite renders. 1. Launch `sprimo-app.exe`; verify default sprite renders.
2. Verify no terminal window appears when launching release build by double-click. 2. Verify no terminal window appears when launching release build by double-click.
3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces interactive mode. 3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces visibility and top-most recovery.
4. Verify click-through and always-on-top toggles via API commands. 4. Verify `SetFlags` applies always-on-top and visibility via API commands.
5. Verify `/v1/health` and `/v1/state` behavior with auth. 5. Verify `/v1/health` and `/v1/state` behavior with auth.
6. Verify `SetSpritePack`: 6. Verify `SetSpritePack`:
- valid pack switches runtime visuals - valid pack switches runtime visuals
@@ -46,6 +57,7 @@ Run tests from an unpacked ZIP folder, not from the workspace run.
7. Restart app and verify persisted config behavior. 7. Restart app and verify persisted config behavior.
8. Confirm overlay background is transparent (desktop visible behind non-sprite pixels). 8. Confirm overlay background is transparent (desktop visible behind non-sprite pixels).
9. Confirm no magenta matte remains around sprite in default pack. 9. Confirm no magenta matte remains around sprite in default pack.
10. Confirm default startup window footprint is reduced (416x416 before runtime pack resize).
## Test Log Template ## Test Log Template
@@ -90,6 +102,10 @@ Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
- `/v1/state` with auth - `/v1/state` with auth
- `/v1/command` - `/v1/command`
- `/v1/commands` - `/v1/commands`
7. Verify tauri frameless drag:
- left-mouse drag moves window
- window remains non-resizable
- moved position updates runtime snapshot `x/y` and persists after restart
### Packaged Mode (Required Once Tauri Packaging Exists) ### Packaged Mode (Required Once Tauri Packaging Exists)

View File

@@ -50,6 +50,9 @@ Frontend:
- `runtime:snapshot` event after command application. - `runtime:snapshot` event after command application.
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale - React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
from runtime snapshot events. from runtime snapshot events.
- Tauri window drag is implemented for undecorated mode:
- left-mouse drag starts native window dragging
- moved position is synced into runtime-core snapshot/config state.
- 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`.

View File

@@ -68,6 +68,10 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
7. Verify sprite-pack loading: 7. Verify sprite-pack loading:
- valid selected pack loads correctly - valid selected pack loads correctly
- invalid pack path failure is surfaced and runtime remains alive - invalid pack path failure is surfaced and runtime remains alive
8. Verify frameless window drag behavior:
- left-mouse drag moves the window
- window remains non-resizable
- moved position is reflected in runtime snapshot state (`x`, `y`) and persists after restart
## API + Runtime Contract Checklist ## API + Runtime Contract Checklist

View File

@@ -2,11 +2,10 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet"; import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
import "./styles.css"; import "./styles.css";
const UI_BUILD_MARKER = "issue2-fix3";
function App(): JSX.Element { function App(): JSX.Element {
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null); const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -56,12 +55,20 @@ function App(): JSX.Element {
}; };
}, []); }, []);
const onMouseDown = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.button !== 0) {
return;
}
void getCurrentWindow().startDragging().catch((err) => {
setError(String(err));
});
}, []);
return ( return (
<main className="app"> <main className="app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} /> <div className="canvas-host" ref={hostRef} />
<section className="debug-panel"> <section className="debug-panel">
<h1>sprimo-tauri</h1> <h1>sprimo-tauri</h1>
<p>ui build: {UI_BUILD_MARKER}</p>
{error !== null ? <p className="error">{error}</p> : null} {error !== null ? <p className="error">{error}</p> : null}
{snapshot === null ? ( {snapshot === null ? (
<p>Loading snapshot...</p> <p>Loading snapshot...</p>

View File

@@ -13,6 +13,7 @@ body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
position: relative; position: relative;
user-select: none;
} }
.canvas-host { .canvas-host {

View File

@@ -63,7 +63,7 @@ This breaks sprite presentation and leaves the UI in an error state.
Implemented: Implemented:
1. `frontend/tauri-ui/package.json` 1. `frontend/tauri-ui/package.json`
- Added `@pixi/assets` dependency. - Removed temporary `@pixi/assets` dependency after switching loader strategy.
2. `frontend/tauri-ui/src/renderer/pixi_pet.ts` 2. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Switched atlas loading to `Assets.load<Texture>(pack.atlas_data_url)`. - Switched atlas loading to `Assets.load<Texture>(pack.atlas_data_url)`.
@@ -85,6 +85,7 @@ Implemented:
6. `frontend/tauri-ui/src/main.tsx` 6. `frontend/tauri-ui/src/main.tsx`
- Added visible UI build marker (`issue2-fix3`) to detect stale embedded frontend artifacts. - Added visible UI build marker (`issue2-fix3`) to detect stale embedded frontend artifacts.
- Removed temporary UI build marker after verification passed.
7. `crates/sprimo-tauri/capabilities/default.json` 7. `crates/sprimo-tauri/capabilities/default.json`
- Added default capability with: - Added default capability with:
@@ -102,6 +103,9 @@ Implemented:
### Commands Run ### Commands Run
- [x] `cargo check --workspace`
- [x] `cargo test --workspace`
- [x] `just qa-validate`
- [x] `cargo check -p sprimo-tauri` - [x] `cargo check -p sprimo-tauri`
- [x] `cargo check -p sprimo-runtime-core` - [x] `cargo check -p sprimo-runtime-core`
- [x] `just build-tauri-ui` - [x] `just build-tauri-ui`
@@ -128,8 +132,8 @@ Implemented:
### Result ### Result
- Current Status: `Fix Implemented` - Current Status: `Verification Passed`
- Notes: build/check/qa validation passed; manual runtime visual verification still required. - Notes: reporter confirmed fix works on runtime behavior; closure gate evidence still to be completed.
## Status History ## Status History
@@ -143,10 +147,12 @@ Implemented:
- `2026-02-13 15:05` - codex - `In Progress` - added explicit UI build marker to detect stale executable/frontend embedding. - `2026-02-13 15:05` - codex - `In Progress` - added explicit UI build marker to detect stale executable/frontend embedding.
- `2026-02-13 15:20` - reporter - `In Progress` - provided `issue2-after-fix3` screenshot; stale-build issue resolved, permission + chroma-key defects observed. - `2026-02-13 15:20` - reporter - `In Progress` - provided `issue2-after-fix3` screenshot; stale-build issue resolved, permission + chroma-key defects observed.
- `2026-02-13 15:35` - codex - `Fix Implemented` - added tauri capability permission file and tauri-side chroma-key conversion. - `2026-02-13 15:35` - codex - `Fix Implemented` - added tauri capability permission file and tauri-side chroma-key conversion.
- `2026-02-13 15:50` - reporter - `Verification Passed` - confirmed the runtime fix works.
- `2026-02-13 16:05` - codex - `Verification Passed` - completed workspace check/test checklist and normalized issue record.
## Closure ## Closure
- Current Status: `Fix Implemented` - Current Status: `Verification Passed`
- Close Date: - Close Date:
- Owner: - Owner:
- Linked PR/commit: - Linked PR/commit:

View File

@@ -18,6 +18,27 @@ package-win:
smoke-win: smoke-win:
{{python}} scripts/package_windows.py smoke {{python}} scripts/package_windows.py smoke
build-release-bevy:
cargo build --release -p sprimo-app
build-release-tauri:
just build-tauri-ui
cargo build --release -p sprimo-tauri
package-win-bevy:
{{python}} scripts/package_windows.py package --frontend bevy
smoke-win-bevy:
{{python}} scripts/package_windows.py smoke --frontend bevy
package-win-tauri:
just build-tauri-ui
{{python}} scripts/package_windows.py package --frontend tauri
smoke-win-tauri:
just build-tauri-ui
{{python}} scripts/package_windows.py smoke --frontend tauri
qa-validate: qa-validate:
{{python}} scripts/qa_validate.py {{python}} scripts/qa_validate.py

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build and package a portable Windows ZIP for sprimo-app.""" """Build and package portable Windows ZIPs for Bevy/Tauri frontends."""
from __future__ import annotations from __future__ import annotations
@@ -18,7 +18,6 @@ from typing import Iterable
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" DIST = ROOT / "dist"
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
ASSETS_REL = ROOT / "assets" ASSETS_REL = ROOT / "assets"
@@ -26,8 +25,36 @@ class PackagingError(RuntimeError):
"""Raised when packaging preconditions are not met.""" """Raised when packaging preconditions are not met."""
@dataclass(frozen=True)
class FrontendLayout:
id: str
crate: str
binary_name: str
artifact_name: str
readme_run: str
FRONTENDS: dict[str, FrontendLayout] = {
"bevy": FrontendLayout(
id="bevy",
crate="sprimo-app",
binary_name="sprimo-app.exe",
artifact_name="sprimo-windows-x64",
readme_run="sprimo-app.exe",
),
"tauri": FrontendLayout(
id="tauri",
crate="sprimo-tauri",
binary_name="sprimo-tauri.exe",
artifact_name="sprimo-tauri-windows-x64",
readme_run="sprimo-tauri.exe",
),
}
@dataclass(frozen=True) @dataclass(frozen=True)
class PackageLayout: class PackageLayout:
frontend: str
version: str version: str
zip_path: Path zip_path: Path
checksum_path: Path checksum_path: Path
@@ -72,12 +99,13 @@ def read_version() -> str:
raise PackagingError("could not determine version") raise PackagingError("could not determine version")
def ensure_release_binary() -> Path: def ensure_release_binary(frontend: FrontendLayout) -> Path:
if not BIN_REL.exists(): binary_path = ROOT / "target" / "release" / frontend.binary_name
run(["cargo", "build", "--release", "-p", "sprimo-app"]) if not binary_path.exists():
if not BIN_REL.exists(): run(["cargo", "build", "--release", "-p", frontend.crate])
raise PackagingError(f"release binary missing: {BIN_REL}") if not binary_path.exists():
return BIN_REL raise PackagingError(f"release binary missing: {binary_path}")
return binary_path
def ensure_assets() -> None: def ensure_assets() -> None:
@@ -108,13 +136,13 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest() return digest.hexdigest()
def package() -> PackageLayout: def package(frontend: FrontendLayout) -> PackageLayout:
version = read_version() version = read_version()
ensure_assets() ensure_assets()
binary = ensure_release_binary() binary = ensure_release_binary(frontend)
DIST.mkdir(parents=True, exist_ok=True) DIST.mkdir(parents=True, exist_ok=True)
artifact_name = f"sprimo-windows-x64-v{version}" artifact_name = f"{frontend.artifact_name}-v{version}"
zip_path = DIST / f"{artifact_name}.zip" zip_path = DIST / f"{artifact_name}.zip"
checksum_path = DIST / f"{artifact_name}.zip.sha256" checksum_path = DIST / f"{artifact_name}.zip.sha256"
@@ -122,13 +150,14 @@ def package() -> PackageLayout:
stage = Path(temp_dir) / artifact_name stage = Path(temp_dir) / artifact_name
stage.mkdir(parents=True, exist_ok=True) stage.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary, stage / "sprimo-app.exe") shutil.copy2(binary, stage / frontend.binary_name)
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True) shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
readme = stage / "README.txt" readme = stage / "README.txt"
readme.write_text( readme.write_text(
"Sprimo portable package\n" "Sprimo portable package\n"
"Run: sprimo-app.exe\n" f"Frontend: {frontend.id}\n"
f"Run: {frontend.readme_run}\n"
"Assets are expected at ./assets relative to the executable.\n", "Assets are expected at ./assets relative to the executable.\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -143,11 +172,16 @@ def package() -> PackageLayout:
checksum = sha256_file(zip_path) checksum = sha256_file(zip_path)
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8") checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
return PackageLayout(version=version, zip_path=zip_path, checksum_path=checksum_path) return PackageLayout(
frontend=frontend.id,
version=version,
zip_path=zip_path,
checksum_path=checksum_path,
)
def smoke() -> None: def smoke(frontend: FrontendLayout) -> None:
layout = package() layout = package(frontend)
print(f"package created: {layout.zip_path}") print(f"package created: {layout.zip_path}")
print(f"checksum file: {layout.checksum_path}") print(f"checksum file: {layout.checksum_path}")
@@ -160,7 +194,7 @@ def smoke() -> None:
pkg_root = root_candidates[0] pkg_root = root_candidates[0]
required = [ required = [
pkg_root / "sprimo-app.exe", pkg_root / frontend.binary_name,
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json", pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png", pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
] ]
@@ -175,18 +209,25 @@ def smoke() -> None:
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper") parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
parser.add_argument("command", choices=["package", "smoke"], help="action to execute") parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
parser.add_argument(
"--frontend",
choices=sorted(FRONTENDS.keys()),
default="bevy",
help="frontend package target",
)
return parser.parse_args() return parser.parse_args()
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
frontend = FRONTENDS[args.frontend]
try: try:
if args.command == "package": if args.command == "package":
layout = package() layout = package(frontend)
print(f"created: {layout.zip_path}") print(f"created: {layout.zip_path}")
print(f"sha256: {layout.checksum_path}") print(f"sha256: {layout.checksum_path}")
else: else:
smoke() smoke(frontend)
return 0 return 0
except PackagingError as exc: except PackagingError as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)