From 9ce4a6395b64d949691e3ab0732f5f731de439d2 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 12:20:49 +0800 Subject: [PATCH 1/5] refactor(frontend): own launcher state and check IPC contracts --- .prettierignore | 2 + docs/explanation/architecture.md | 5 + docs/how-to/verify.md | 17 + docs/reference/features/launcher.md | 3 + docs/reference/features/settings.md | 3 + src-tauri/src/launcher/contract_tests.rs | 214 +++++++++ src-tauri/src/launcher/mod.rs | 41 +- src-tauri/src/launcher/preferences.rs | 13 + src-tauri/src/launcher/result.rs | 6 +- src-tauri/src/launcher/warning.rs | 35 ++ src-tauri/src/lib.rs | 18 +- src/App.tsx | 199 +++----- src/Settings.tsx | 137 ++---- src/bridge.ts | 17 +- src/launcherController.ts | 164 +++++++ src/nativeSubscriptions.ts | 43 ++ src/settingsDraft.ts | 73 +++ tests/fixtures/ipc-contract.ts | 554 +++++++++++++++++++++++ tests/ipc-contract.spec.ts | 184 ++++++++ tests/ipc-contract.ts | 29 ++ tests/ipc-probe.ts | 52 +++ tests/launcher-controller.spec.ts | 156 +++++++ tests/launcher-warnings.spec.ts | 102 +++++ tests/mock-backend.ts | 13 +- tests/native-subscriptions.spec.ts | 65 +++ tests/settings-draft.spec.ts | 71 +++ 26 files changed, 1974 insertions(+), 242 deletions(-) create mode 100644 src-tauri/src/launcher/contract_tests.rs create mode 100644 src-tauri/src/launcher/warning.rs create mode 100644 src/launcherController.ts create mode 100644 src/nativeSubscriptions.ts create mode 100644 src/settingsDraft.ts create mode 100644 tests/fixtures/ipc-contract.ts create mode 100644 tests/ipc-contract.spec.ts create mode 100644 tests/ipc-contract.ts create mode 100644 tests/ipc-probe.ts create mode 100644 tests/launcher-controller.spec.ts create mode 100644 tests/launcher-warnings.spec.ts create mode 100644 tests/native-subscriptions.spec.ts create mode 100644 tests/settings-draft.spec.ts diff --git a/.prettierignore b/.prettierignore index a71b510..aae214e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,5 @@ native-build designs .hallmark site-dist +# Canonical serde output, compared byte-for-byte by Rust contract tests. +tests/fixtures/ipc-contract.ts diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index fd1cd36..1794685 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -9,6 +9,7 @@ The path is `query → SearchManager → providers → ranking → top 30 result - Currency lookup reads a shared, immutable rate table in memory. Network requests and SQLite writes run outside the search lock. `currency.rs` contains the source request and parser; the calculator does not depend on the HTTP response format. The HTTP client reuses the reqwest version already required by Tauri and uses the OS TLS stack. - Clipboard capture, copy, delete, and clear use the same storage lock. A generation number rejects reads already in progress when an entry is removed. Search never waits for disk access. Linux coalesces pending clipboard observations in a bounded queue. Results include short text summaries; a separate request fetches the selected entry's preview. - SolidJS keeps UI state and sends queries without a debounce timer. It sends one search request at a time and retains only the newest waiting query. This prevents IPC arrival order from cancelling the current query when startup events overlap. Request numbers prevent late replies from replacing newer results. `src/search.ts` holds this queue and the rule that keeps the selected result across refreshes; it has no Solid dependency and has its own tests. Enter cannot execute an old result while a new query is pending. +- `src/launcherController.ts` owns the frontend search session, result reconciliation, selection, pending state, and visibility gating. The view keeps focus, keyboard navigation, dialogs, and rendering. `src/nativeSubscriptions.ts` owns native listeners, including registrations that finish after a window is disposed; both windows use it. `src/settingsDraft.ts` merges saved settings into local drafts without discarding local field edits or incomplete folder input. - Hiding the window stops frontend search requests, drops waiting input, and ignores any late search reply. File indexing and clipboard capture continue in Rust. Reopening requests current results. A background refresh preserves the user's latest selection for the same query; a new query selects its first result. - The frontend sends a result ID and an action. Rust resolves app paths, indexed file paths, emoji values, and calculation values. It checks that a file still exists before opening it. A bounded cache holds the last 32 calculation results so copying an issued result does not evaluate it again. The webview has no general shell, opener, filesystem, clipboard, or global-shortcut permissions. - System command IDs resolve against the Rust catalog. Rust owns command aliases, confirmation text, and the confirmation rule. The frontend supplies explicit consent after the dialog. Platform modules contain the native API calls; no shell command text comes from the webview. @@ -19,6 +20,10 @@ The providers use direct methods. No provider trait is needed. The shared result The plugin system and public SDK are deferred. Preserve the current module responsibilities so a future SDK can use them. Follow the [future plugin support decision](plugin-readiness.md) when changing search, actions, storage, or frontend communication. Add plugin infrastructure only when implementation starts for a concrete command. +The internal IPC contract is checked without a schema framework or public SDK. Rust serializes canonical examples in `launcher/contract_tests.rs`; the checked-in `tests/fixtures/ipc-contract.ts` is consumed by TypeScript and browser tests. Examples cover results, all tool details, settings, optional fields, and targeted structured warnings. Full verification rejects serialization drift, missing enum examples, and mismatches between real bridge calls and registered Rust command arguments. See [verification](../how-to/verify.md#check-the-internal-ipc-contract) for the update procedure. + +Startup warnings and search storage warnings carry a stable code, display message, and retryability. Settings updates clear only repaired warning categories, never warnings matched by English text. Other display-only command errors remain strings. Storage health stays in Rust; the launcher does not infer retryability from a storage message. + The frontend lives in `src/`. The Rust application lives in `src-tauri/`. Shared frontend styles use `tokens.css`. Tests and build scripts are separate from application code. See the [feature catalog](../reference/features/README.md) for supported behavior. diff --git a/docs/how-to/verify.md b/docs/how-to/verify.md index 2f310ce..a3fdca4 100644 --- a/docs/how-to/verify.md +++ b/docs/how-to/verify.md @@ -43,6 +43,23 @@ The focused wrapper selects its own port and output directory. It accepts test-f Use focused tests during diagnosis. After the final relevant edit, repeat affected proofs and run `verify:full` for source changes. For each task, record expected behavior, required platforms, outcomes, and evidence paths under `.local/` or `test-results/`. A command pass does not establish behavior outside that command's coverage. Missing required evidence prevents a verified result. +## Check the internal IPC contract + +`verify:full` checks the actual Rust serialization against [canonical fixtures](../../tests/fixtures/ipc-contract.ts), type-checks those fixtures against the frontend bridge, and checks real bridge invocations against Rust command registration and argument names/types. Result kinds, actions, search modes, tool details, settings, and targeted structured warnings are covered. These are internal contracts, not a public SDK. Command errors that are only displayed can still be strings. + +When intentionally changing the wire contract, regenerate its examples, review the diff, and run the normal checks: + +```sh +TINYDASH_UPDATE_CONTRACTS=1 bun run test:rust -- serialized_ipc_contracts_match_frontend_fixture +bun run typecheck +bun run verify:browser tests/ipc-contract.spec.ts +bun run verify:full +``` + +Do not set `TINYDASH_UPDATE_CONTRACTS` in CI. The normal Rust test compares the fixture byte-for-byte and does not update it. The fixture is excluded from Prettier because serde owns its formatting. Add representative samples for new optional fields or variants; the contract tests require every result, detail, action, mode, and warning-code variant to have an example. Changes to a command signature may require a new probe or supported argument type in the command consistency test. + +These checks prove serialization and bridge consistency, not native IPC authorization or OS effects. Use the desktop procedures for those. + ## CI triggers The Checks workflow skips branch pushes and pull requests when all changed files are Markdown files with the `.md` or `.markdown` extension, or files under `docs/`. This also skips the desktop builds and native app checks. If any other file changes, the workflow runs. The separate Verification tools workflow checks changes to the verification skill, procedures, feature map, and tools, including Markdown-only changes. It does not replace the agent exercises in [test the verification procedure](verify-verification.md). diff --git a/docs/reference/features/launcher.md b/docs/reference/features/launcher.md index 746b561..b1f4978 100644 --- a/docs/reference/features/launcher.md +++ b/docs/reference/features/launcher.md @@ -18,8 +18,11 @@ Run this browser recipe from the repository root. It retains successful traces i ```sh bun run verify:browser tests/welcome.spec.ts tests/categories.spec.ts tests/launcher.spec.ts +bun run verify:browser tests/launcher-controller.spec.ts tests/native-subscriptions.spec.ts tests/launcher-warnings.spec.ts tests/ipc-contract.spec.ts ``` +The standalone controller checks cover result reconciliation, refresh selection, hidden/disposed replies, failure state, and browser-preview gating. Subscription checks cover late registrations and teardown. Warning tests use changed display text to prove that settings repair follows structured codes rather than English prefixes; unrelated platform warnings remain visible. A storage warning must leave search results usable. + For affected backend behavior: ```sh diff --git a/docs/reference/features/settings.md b/docs/reference/features/settings.md index 72e099c..96d8b55 100644 --- a/docs/reference/features/settings.md +++ b/docs/reference/features/settings.md @@ -20,8 +20,11 @@ Run this browser recipe from the repository root. It retains successful traces i ```sh bun run verify:browser tests/settings.spec.ts tests/tinycast-features.spec.ts tests/release-config.spec.ts +bun run verify:browser tests/settings-draft.spec.ts tests/native-subscriptions.spec.ts ``` +Standalone draft tests check local edits against incoming saved values, nested application preferences, deletions, category normalization, and incomplete folder input. Browser tests additionally exercise clean and dirty forms receiving changes from another window. A saved-settings event must not discard unrelated local edits. + For affected backend behavior: ```sh diff --git a/src-tauri/src/launcher/contract_tests.rs b/src-tauri/src/launcher/contract_tests.rs new file mode 100644 index 0000000..4c27df7 --- /dev/null +++ b/src-tauri/src/launcher/contract_tests.rs @@ -0,0 +1,214 @@ +//! Canonical wire examples are produced by serde, then type-checked by TypeScript. +use super::{Action, ActionConfirmation, ResultKind, SearchResponse, SearchResult, ToolDetail}; +use crate::{ + launcher::{currency::CurrencyStatus, files::FileStatus, pins::ResultPin, query::SearchMode}, + settings::{AppPreference, CategoryShortcut, Settings, WebSearch}, +}; +use serde_json::json; + +#[test] +fn serialized_ipc_contracts_match_frontend_fixture() { + let modes = vec![ + SearchMode::All, + SearchMode::Apps, + SearchMode::Files, + SearchMode::Emoji, + SearchMode::Calculator, + SearchMode::Clipboard, + SearchMode::System, + SearchMode::Password, + SearchMode::Timezone, + SearchMode::Url, + SearchMode::Web, + ]; + let actions = vec![ + Action::Launch, + Action::Open, + Action::Reveal, + Action::Copy, + Action::Delete, + Action::Run, + Action::Regenerate, + ]; + let kinds = [ + ResultKind::App, + ResultKind::File, + ResultKind::Folder, + ResultKind::Calculation, + ResultKind::Emoji, + ResultKind::Clipboard, + ResultKind::SystemCommand, + ResultKind::Password, + ResultKind::Timezone, + ResultKind::CleanedUrl, + ResultKind::WebSearch, + ]; + let results = kinds + .into_iter() + .enumerate() + .map(|(index, kind)| SearchResult { + id: format!("result:{index}"), + kind, + title: "Fixture".into(), + subtitle: "Wire example".into(), + path: None, + score: 10, + icon: None, + primary_action: Action::Copy, + secondary_actions: vec![], + pin: None, + confirmation: None, + detail: None, + }) + .collect(); + let details = vec![ + ToolDetail::Password { + variant: "password".into(), + entropy_bits: 128, + strength: "Strong".into(), + }, + ToolDetail::Timezone { + source: "10:00".into(), + local: "17:00".into(), + source_zone: "UTC".into(), + target_zone: None, + ambiguous: false, + }, + ToolDetail::Timezone { + source: "10:00".into(), + local: "17:00".into(), + source_zone: "UTC".into(), + target_zone: Some("Asia/Bangkok".into()), + ambiguous: true, + }, + ToolDetail::DateCalculation { + expression: "tomorrow".into(), + based_on: "2026-09-28".into(), + result: "2026-09-29".into(), + }, + ToolDetail::CleanedUrl { + original: "https://example.com/?utm_source=test".into(), + removed: 1, + }, + ToolDetail::WebSearch { + engine: "Example".into(), + query: "hello world".into(), + url: "https://example.com/?q=hello%20world".into(), + }, + ]; + let full_result = SearchResult { + id: "full".into(), + kind: ResultKind::SystemCommand, + title: "Restart".into(), + subtitle: "Confirmation required".into(), + path: Some("/example/file".into()), + score: 100, + icon: Some("data:image/png;base64,example".into()), + primary_action: Action::Run, + secondary_actions: actions.clone(), + pin: Some(ResultPin { + key: "system:restart".into(), + categories: modes.clone(), + }), + confirmation: Some(ActionConfirmation { + title: "Restart?".into(), + description: "Unsaved work can be lost.".into(), + confirm_label: "Restart".into(), + }), + detail: Some(details[0].clone()), + }; + let settings = Settings { + category_shortcuts: vec![CategoryShortcut { + mode: SearchMode::Apps, + shortcut: "Control+Shift+KeyA".into(), + }], + app_preferences: [( + "app:example".into(), + AppPreference { + aliases: vec!["editor".into()], + hidden: true, + }, + )] + .into(), + web_searches: vec![WebSearch { + name: "Example".into(), + keyword: "ex".into(), + template: "https://example.com/?q={query}".into(), + enabled: true, + }], + file_search_roots: Some(vec!["/example".into()]), + ..Settings::default() + }; + // Also exercise deserialization of the same settings sent back by save_settings. + let settings_json = serde_json::to_value(&settings).unwrap(); + assert_eq!( + serde_json::from_value::(settings_json).unwrap(), + settings + ); + let response = SearchResponse { + preferred_selection_id: None, + results, + total: 11, + indexing: false, + index_error: None, + notice: None, + storage_error: None, + files: FileStatus { + total: 3, + indexing: false, + warning: None, + }, + currency: CurrencyStatus { + as_of: None, + refreshing: false, + warning: None, + }, + }; + let warning_response = SearchResponse { + preferred_selection_id: Some("clipboard:1".into()), + results: vec![], + total: 0, + indexing: true, + index_error: Some("Index unavailable".into()), + notice: Some("Try a shorter query".into()), + storage_error: Some(crate::launcher::warning::LauncherWarning::new( + crate::launcher::warning::WarningCode::StorageUnavailable, + "Storage unavailable", + false, + )), + files: FileStatus { + total: 1, + indexing: true, + warning: Some("Scan incomplete".into()), + }, + currency: CurrencyStatus { + as_of: Some("2026-09-28".into()), + refreshing: true, + warning: Some("Rates are old".into()), + }, + }; + let value = json!({ + "modes": modes, "actions": actions, "response": response, "warningResponse": warning_response, + "fullResult": full_result, "details": details, "settings": settings, + "defaults": Settings::default(), + "launcher": crate::launcher::contract_launcher_info(), + "settingsInfo": crate::launcher::preferences::contract_settings_info(), + "clipboard": crate::providers::clipboard::ClipboardEntry { id: 42, content: "Example".into(), created_at: 1, last_used_at: None }, + "imported": crate::launcher::portability::SettingsImport { settings: Settings::default(), ignored_keys: vec!["futureKey".into()], appearance: Some("dark".into()), compact: Some(true), follow_system_glass: None }, + "update": crate::launcher::updates::UpdateStatus { available: true, version: Some("0.2.0".into()), notes: None, message: "Update available".into() }, + }); + let source = format!( + "// Generated by serialized_ipc_contracts_match_frontend_fixture. Do not edit.\nimport type {{ ContractFixture }} from \"../ipc-contract\";\n\nexport const contracts = {} satisfies ContractFixture;\n", + serde_json::to_string_pretty(&value).unwrap() + ); + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/ipc-contract.ts"); + if std::env::var_os("TINYDASH_UPDATE_CONTRACTS").is_some() { + std::fs::write(&path, &source).unwrap(); + } + assert_eq!( + std::fs::read_to_string(path).unwrap(), + source, + "IPC serialization drift. Regenerate with TINYDASH_UPDATE_CONTRACTS=1, review the diff, then typecheck and test." + ); +} diff --git a/src-tauri/src/launcher/mod.rs b/src-tauri/src/launcher/mod.rs index a2057e7..bedebb2 100644 --- a/src-tauri/src/launcher/mod.rs +++ b/src-tauri/src/launcher/mod.rs @@ -13,6 +13,7 @@ pub mod search; pub mod startup; mod storage; pub mod updates; +pub mod warning; pub mod window; use std::sync::{ @@ -32,6 +33,7 @@ use crate::{ use query::SearchMode; use result::SearchResponse; use search::SearchManager; +use warning::{LauncherWarning, WarningCode}; pub struct LauncherState { pub search: Mutex, @@ -42,7 +44,7 @@ pub struct LauncherState { settings: RwLock, pub settings_update: Mutex<()>, pub shortcut_recording: AtomicBool, - pub warnings: Vec, + pub warnings: Vec, pub index_error: Mutex>, pub storage: storage::Storage, pub clipboard: clipboard::Monitor, @@ -51,7 +53,7 @@ pub struct LauncherState { } impl LauncherState { - pub fn new(settings: Settings, warnings: Vec) -> Self { + pub fn new(settings: Settings, warnings: Vec) -> Self { let mut search = SearchManager::default(); search.apply_settings(&settings); Self { @@ -124,11 +126,33 @@ impl LauncherState { pub struct LauncherInfo { settings: Settings, platform: &'static str, - warnings: Vec, + warnings: Vec, visible: bool, initial_mode: Option, } +#[cfg(test)] +fn contract_launcher_info() -> LauncherInfo { + LauncherInfo { + settings: Settings::default(), + platform: "linux", + warnings: [ + WarningCode::SettingsRead, + WarningCode::ShortcutRegistration, + WarningCode::ShortcutsUnavailable, + WarningCode::ClipboardLimited, + WarningCode::TrayUnavailable, + WarningCode::StorageUnavailable, + WarningCode::ClipboardUnavailable, + ] + .into_iter() + .map(|code| LauncherWarning::new(code, "Fixture warning", false)) + .collect(), + visible: true, + initial_mode: Some(SearchMode::Apps), + } +} + #[tauri::command] pub async fn launcher_ready(app: AppHandle) -> Result { let worker_app = app.clone(); @@ -186,7 +210,16 @@ pub async fn search( storage_error: state .storage .warning() - .or_else(|| state.clipboard.warning()), + // Storage health is owned by Storage. Until it supplies typed + // retryability, do not guess from its human-readable message. + .map(|message| { + LauncherWarning::new(WarningCode::StorageUnavailable, message, false) + }) + .or_else(|| { + state.clipboard.warning().map(|message| { + LauncherWarning::new(WarningCode::ClipboardUnavailable, message, true) + }) + }), total: search.app_count(), files: state.files.status(search.file_count()), currency: state.currency.status(search.rates()), diff --git a/src-tauri/src/launcher/preferences.rs b/src-tauri/src/launcher/preferences.rs index 159f579..56558ec 100644 --- a/src-tauri/src/launcher/preferences.rs +++ b/src-tauri/src/launcher/preferences.rs @@ -22,6 +22,19 @@ pub struct SettingsInfo { shortcuts_available: bool, } +#[cfg(test)] +pub(super) fn contract_settings_info() -> SettingsInfo { + SettingsInfo { + settings: Settings::default(), + defaults: Settings::default(), + platform: "linux", + version: "0.1.3".into(), + config_path: "/example/settings.json".into(), + data_path: "/example/tinydash.sqlite3".into(), + shortcuts_available: true, + } +} + #[tauri::command] pub fn get_settings(app: AppHandle) -> Result { Ok(SettingsInfo { diff --git a/src-tauri/src/launcher/result.rs b/src-tauri/src/launcher/result.rs index 3feef8d..57db2a2 100644 --- a/src-tauri/src/launcher/result.rs +++ b/src-tauri/src/launcher/result.rs @@ -1,5 +1,9 @@ use serde::{Deserialize, Serialize}; +#[cfg(test)] +#[path = "contract_tests.rs"] +mod contract_tests; + #[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ResultKind { @@ -131,7 +135,7 @@ pub struct SearchResponse { pub indexing: bool, pub index_error: Option, pub notice: Option, - pub storage_error: Option, + pub storage_error: Option, pub files: super::files::FileStatus, pub currency: super::currency::CurrencyStatus, } diff --git a/src-tauri/src/launcher/warning.rs b/src-tauri/src/launcher/warning.rs new file mode 100644 index 0000000..2f7526d --- /dev/null +++ b/src-tauri/src/launcher/warning.rs @@ -0,0 +1,35 @@ +use serde::Serialize; + +/// Stable categories for warnings the launcher must act on. Command failures +/// that are only displayed remain strings; do not classify their English text. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WarningCode { + SettingsRead, + ShortcutRegistration, + ShortcutsUnavailable, + ClipboardLimited, + TrayUnavailable, + StorageUnavailable, + ClipboardUnavailable, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LauncherWarning { + pub code: WarningCode, + pub message: String, + /// Whether repeating the originating operation can recover without restart + /// or configuration repair. This is metadata, not an instruction to retry. + pub retryable: bool, +} + +impl LauncherWarning { + pub fn new(code: WarningCode, message: impl Into, retryable: bool) -> Self { + Self { + code, + message: message.into(), + retryable, + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4a07b6..15997f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,7 +15,11 @@ use tauri::{ }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; -use launcher::{LauncherState, window}; +use launcher::{ + LauncherState, + warning::{LauncherWarning, WarningCode}, + window, +}; fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let show = MenuItem::with_id(app, "show", "Open TinyDash", true, None::<&str>)?; @@ -115,15 +119,15 @@ pub fn run() -> anyhow::Result<()> { Ok(settings) => settings, Err(error) => { tracing::warn!(%error, "Using default settings"); - warnings.push("Could not read settings. TinyDash is using the default settings.".into()); + warnings.push(LauncherWarning::new(WarningCode::SettingsRead, "Could not read settings. TinyDash is using the default settings.", false)); settings::Settings::fresh_install() } }; if platform::is_wayland() { - warnings.push("Global shortcuts need X11. On Wayland, assign a desktop shortcut to start TinyDash.".into()); + warnings.push(LauncherWarning::new(WarningCode::ShortcutsUnavailable, "Global shortcuts need X11. On Wayland, assign a desktop shortcut to start TinyDash.", false)); if settings.clipboard_history_enabled { - warnings.push("Wayland can limit background clipboard access. Open TinyDash after copying text if an entry is missing.".into()); + warnings.push(LauncherWarning::new(WarningCode::ClipboardLimited, "Wayland can limit background clipboard access. Open TinyDash after copying text if an entry is missing.", false)); } } else { let shortcut_result = app.handle().plugin( @@ -143,12 +147,12 @@ pub fn run() -> anyhow::Result<()> { ); if let Err(error) = shortcut_result { tracing::warn!(%error, "Global shortcut is unavailable"); - warnings.push(format!("Could not register {}. Use the tray menu or change settings.json.", settings.shortcut)); + warnings.push(LauncherWarning::new(WarningCode::ShortcutsUnavailable, format!("Could not register {}. Use the tray menu or change settings.json.", settings.shortcut), false)); } else { for shortcut in settings.shortcuts() { if let Err(error) = app.global_shortcut().register(shortcut) { tracing::warn!(%error, shortcut, "Global shortcut is unavailable"); - warnings.push(format!("Could not register {shortcut}. Use the tray menu or change Settings.")); + warnings.push(LauncherWarning::new(WarningCode::ShortcutRegistration, format!("Could not register {shortcut}. Use the tray menu or change Settings."), true)); } } } @@ -156,7 +160,7 @@ pub fn run() -> anyhow::Result<()> { if let Err(error) = setup_tray(app) { tracing::warn!(%error, "Tray icon is unavailable"); - warnings.push("The tray icon is unavailable. Start TinyDash again to show the running launcher.".into()); + warnings.push(LauncherWarning::new(WarningCode::TrayUnavailable, "The tray icon is unavailable. Start TinyDash again to show the running launcher.", false)); if let Some(window) = app.get_webview_window("main") { window.set_skip_taskbar(false)?; } } else { #[cfg(target_os = "macos")] diff --git a/src/App.tsx b/src/App.tsx index 3302c6a..8dd2aa7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,9 +8,14 @@ import { onMount, Show, } from "solid-js"; -import { createStore, reconcile, unwrap } from "solid-js/store"; +import { unwrap } from "solid-js/store"; +import { + createLauncherController, + receiveLauncherSettings, +} from "./launcherController"; import { isTauri } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { listen } from "@tauri-apps/api/event"; +import { createNativeSubscriptions } from "./nativeSubscriptions"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { backend, @@ -18,8 +23,6 @@ import { type LauncherInfo, type SearchResult, type SearchMode, - type FileStatus, - type CurrencyStatus, } from "./bridge"; import Icon from "./components/Icon"; import ResultIcon from "./components/ResultIcon"; @@ -43,7 +46,6 @@ import { normalizeCategories, resultCategories, } from "./categories"; -import { chooseSelection, createSearchQueue } from "./search"; const groupLabels: Record = { app: "Applications", @@ -78,36 +80,41 @@ export default function App( readFollowSystemGlass(), ); const [compact, setCompact] = createSignal(readCompact()); - const [visible, setVisible] = createSignal(true); - const [query, setQuery] = createSignal(""); - const [mode, setMode] = createSignal("all"); - const [resultView, setResultView] = createStore<{ results: SearchResult[] }>({ - results: [], + const controller = createLauncherController({ + desktop, + send: backend.search, }); - const results = () => resultView.results; - const setResults = (next: SearchResult[]) => - setResultView("results", reconcile(next, { key: "id" })); - const [selected, setSelected] = createSignal(0); + const { + visible, + setVisible, + query, + setQuery, + mode, + setMode, + results, + selected, + setSelected, + current, + pending, + total, + indexing, + setIndexing, + files, + setFiles, + currency, + error, + setError, + indexError, + storageError, + notice, + setNotice, + search, + changeQuery, + markSelectionChanged, + } = controller; const [pinBusy, setPinBusy] = createSignal(false); const [info, setInfo] = createSignal(); - const [total, setTotal] = createSignal(0); - const [indexing, setIndexing] = createSignal(desktop); - const [files, setFiles] = createSignal({ - total: 0, - indexing: desktop, - warning: null, - }); - const [pending, setPending] = createSignal(false); - const [currency, setCurrency] = createSignal({ - asOf: null, - refreshing: false, - warning: null, - }); const [busy, setBusy] = createSignal(false); - const [error, setError] = createSignal(); - const [indexError, setIndexError] = createSignal(); - const [storageError, setStorageError] = createSignal(); - const [notice, setNotice] = createSignal(); const [menuOpen, setMenuOpen] = createSignal(false); const [menuFilter, setMenuFilter] = createSignal(""); const [clipboardTool, setClipboardTool] = createSignal<{ @@ -124,9 +131,7 @@ export default function App( let list!: HTMLUListElement; let categoryBar!: HTMLDivElement; let disposed = false; - let displayedQuery: { value: string; mode: SearchMode } | undefined; - let selectionChangedByUser = false; - const unlisteners: UnlistenFn[] = []; + const subscriptions = createNativeSubscriptions(listen); const modifier = () => (info()?.platform === "macos" ? "⌘" : "Ctrl"); const enabledCategories = createMemo(() => @@ -135,7 +140,6 @@ export default function App( const visibleCategories = createMemo(() => categories.filter(({ id }) => enabledCategories().includes(id)), ); - const current = () => results()[selected()]; const welcome = () => mode() === "all" && !query().trim() && results().length === 0; const isPinned = (result?: SearchResult, category = mode()) => @@ -166,10 +170,10 @@ export default function App( error() ?? notice() ?? indexError() ?? - storageError() ?? + storageError()?.message ?? (mode() === "all" || mode() === "files" ? files().warning : undefined) ?? (mode() === "calculator" ? currency().warning : undefined) ?? - info()?.warnings[0]; + info()?.warnings[0]?.message; const primaryLabel = () => current()?.kind === "password" ? "Copy password" @@ -504,59 +508,6 @@ export default function App( } } - const searches = createSearchQueue({ - send: ({ value, mode }) => backend.search(value, mode), - apply(request, response) { - const index = chooseSelection({ - request, - results: response.results, - preferredSelectionId: response.preferredSelectionId, - displayed: displayedQuery, - current: current(), - selected: selected(), - selectionChangedByUser, - }); - displayedQuery = { value: request.value, mode: request.mode }; - batch(() => { - setResults(response.results); - setSelected(index); - setTotal(response.total); - setIndexing(response.indexing); - setFiles(response.files); - setCurrency(response.currency); - setIndexError(response.indexError ?? undefined); - setStorageError(response.storageError ?? undefined); - setNotice(response.notice ?? undefined); - }); - }, - fail(_request, reason) { - setResults([]); - setError(String(reason)); - }, - settled: () => setPending(false), - }); - - function search(value = query(), preserveSelection = false) { - if (!desktop || !visible() || disposed) return Promise.resolve(); - if (!preserveSelection) selectionChangedByUser = false; - setPending(true); - if ( - mode() === "all" && - !value.trim() && - (displayedQuery?.mode !== "all" || displayedQuery.value.trim()) - ) { - setResults([]); - } - setNotice(undefined); - return searches.submit({ value, mode: mode(), preserveSelection }); - } - - function changeQuery(value: string) { - setQuery(value); - setError(undefined); - void search(value); - } - async function togglePin(category: SearchMode) { const result = current(); if (!canOpen() || pinBusy() || !result?.pin) return; @@ -893,7 +844,7 @@ export default function App( event.preventDefault(); if (results().length) { const count = results().length; - selectionChangedByUser = true; + markSelectionChanged(); setSelected((index) => { if ( emojiGrid && @@ -970,12 +921,17 @@ export default function App( onMount(() => { focusInput(); - void watchAppearance(setAppearance, setCompact, setFollowSystemGlass).then( - (stop) => { - if (disposed) stop(); - else unlisteners.push(stop); - }, - ); + void subscriptions + .own( + watchAppearance( + subscriptions.guard(setAppearance), + subscriptions.guard(setCompact), + subscriptions.guard(setFollowSystemGlass), + ), + ) + .catch((reason) => { + if (!disposed) setError(String(reason)); + }); document.addEventListener("keydown", onKey); document.addEventListener("compositionstart", startComposition); document.addEventListener("compositionend", endComposition); @@ -983,38 +939,11 @@ export default function App( if (!desktop) return; void (async () => { try { - const register = async ( - name: string, - callback: (payload: unknown) => void, - ) => { - const stop = await listen(name, (event) => callback(event.payload)); - if (disposed) stop(); - else unlisteners.push(stop); - }; + const { register } = subscriptions; await Promise.all([ - register("settings-changed", (payload) => { + register("settings-changed", (settings) => { setInfo((current) => - current - ? { - ...current, - settings: payload as LauncherInfo["settings"], - warnings: current.warnings.filter( - (warning) => - !( - warning.startsWith("Could not register ") && - (current.settings.shortcut !== - (payload as LauncherInfo["settings"]).shortcut || - JSON.stringify( - current.settings.categoryShortcuts, - ) !== - JSON.stringify( - (payload as LauncherInfo["settings"]) - .categoryShortcuts, - )) - ) && !warning.startsWith("Could not read settings."), - ), - } - : current, + current ? receiveLauncherSettings(current, settings) : current, ); keepVisibleCategory(); void search(query(), true); @@ -1068,22 +997,20 @@ export default function App( }), register("launcher-hidden", () => { clearComposition(); - setVisible(false); - // One running Rust search may finish. Ignore its reply and drop - // waiting input. Opening the window always requests current data. - searches.cancel(); - setPending(false); + controller.hidden(); }), ]); if (disposed) return; const initial = await backend.ready(); + if (disposed) return; setInfo(initial); setVisible(initial.visible ?? true); if (initial.initialMode) setMode(initial.initialMode); else keepVisibleCategory(); await search(); - focusInput(); + if (!disposed) focusInput(); } catch (reason) { + if (disposed) return; setIndexing(false); setError(`Could not connect to TinyDash. ${String(reason)}`); } @@ -1092,8 +1019,8 @@ export default function App( onCleanup(() => { disposed = true; - searches.dispose(); - unlisteners.forEach((stop) => stop()); + controller.dispose(); + subscriptions.dispose(); document.removeEventListener("keydown", onKey); document.removeEventListener("compositionstart", startComposition); document.removeEventListener("compositionend", endComposition); @@ -1320,7 +1247,7 @@ export default function App( }} onPointerMove={() => { if (!pending()) { - selectionChangedByUser = true; + markSelectionChanged(); setSelected(index()); } }} diff --git a/src/Settings.tsx b/src/Settings.tsx index 8413dd7..4b1fb68 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -9,7 +9,13 @@ import { Show, } from "solid-js"; import { isTauri } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { listen } from "@tauri-apps/api/event"; +import { createNativeSubscriptions } from "./nativeSubscriptions"; +import { + folderModeFor, + mergeSettingsDraft, + type FolderMode, +} from "./settingsDraft"; import { backend, type SearchMode, @@ -91,7 +97,6 @@ const sections = [ }, ] as const; type Section = (typeof sections)[number]["id"]; -type FolderMode = "default" | "custom" | "off"; type ShortcutTarget = "global" | SearchMode; const lines = (text: string) => [ ...new Set( @@ -101,24 +106,6 @@ const lines = (text: string) => [ .filter(Boolean), ), ]; -const folderModeFor = (value: SettingsValues): FolderMode => - value.fileSearchRoots === null - ? "default" - : value.fileSearchRoots.length - ? "custom" - : "off"; - -function mergeDraft(previous: T, draft: T, incoming: T): T { - const merged = { ...incoming }; - const keys = new Set([...Object.keys(previous), ...Object.keys(draft)]); - for (const key of keys as Set) { - if (JSON.stringify(draft[key]) === JSON.stringify(previous[key])) continue; - if (Object.hasOwn(draft, key)) merged[key] = draft[key]; - else delete merged[key]; - } - return merged; -} - function Toggle(props: { label: string; hint: string; @@ -180,7 +167,7 @@ export default function Settings() { let recordingSequence = 0; let recorder!: HTMLButtonElement; let content!: HTMLDivElement; - const stops: UnlistenFn[] = []; + const subscriptions = createNativeSubscriptions(listen); const value = () => draft()!; const currentSection = () => sections.find((item) => item.id === section())!; const dirty = createMemo( @@ -231,52 +218,17 @@ export default function Settings() { } function receiveSettings(settings: SettingsValues) { - const next = { - ...settings, - visibleCategories: normalizeCategories(settings.visibleCategories), - }; - const previous = saved(); - const current = draft(); - const merged = - previous && current ? mergeDraft(previous, current, next) : next; - if (previous && current) { - merged.appPreferences = mergeDraft( - previous.appPreferences, - current.appPreferences, - next.appPreferences, - ); - for (const [id, preference] of Object.entries(current.appPreferences)) { - if (next.appPreferences[id]) { - merged.appPreferences[id] = mergeDraft( - previous.appPreferences[id] ?? { aliases: [], hidden: false }, - preference, - next.appPreferences[id], - ); - } - } - } + const merged = mergeSettingsDraft(saved(), draft(), settings, folderMode()); batch(() => { - setInfo((info) => (info ? { ...info, settings: next } : info)); - setSaved(next); - if (!previous || !current) { - resetDraft(merged); - return; + setInfo((info) => (info ? { ...info, settings: merged.saved } : info)); + setSaved(merged.saved); + setDraft(merged.draft); + if (merged.updateFolders) { + setFolderMode(folderModeFor(merged.draft)); + setFoldersText(merged.draft.fileSearchRoots?.join("\n") ?? ""); } - setDraft(merged); - // Keep incomplete folder input while another window changes settings. - if ( - JSON.stringify(current.fileSearchRoots) === - JSON.stringify(previous.fileSearchRoots) && - folderMode() === folderModeFor(previous) - ) { - setFolderMode(folderModeFor(merged)); - setFoldersText(merged.fileSearchRoots?.join("\n") ?? ""); - } - if ( - JSON.stringify(current.fileSearchExcludedDirs) === - JSON.stringify(previous.fileSearchExcludedDirs) - ) { - setExcludedText(merged.fileSearchExcludedDirs.join("\n")); + if (merged.updateExcluded) { + setExcludedText(merged.draft.fileSearchExcludedDirs.join("\n")); } }); } @@ -578,13 +530,10 @@ export default function Settings() { document.addEventListener("keydown", onKey, true); window.addEventListener("blur", onBlur); if (desktop) { - void listen("settings-changed", (event) => - receiveSettings(event.payload), - ) - .then((stop) => { - if (disposed) return stop(); - stops.push(stop); - return load(); + void subscriptions + .register("settings-changed", receiveSettings) + .then((active) => { + if (active) return load(); }) .catch((reason) => { if (!disposed) setError(String(reason)); @@ -592,29 +541,37 @@ export default function Settings() { } else { void load(); } - void watchAppearance( - (value) => { - setAppearance(value); - setSavedAppearance(value); - }, - (value) => { - setCompact(value); - setSavedCompact(value); - }, - (value) => { - setFollowSystemGlass(value); - setSavedSystemGlass(value); - }, - ).then((stop) => (disposed ? stop() : stops.push(stop))); + void subscriptions + .own( + watchAppearance( + subscriptions.guard((value) => { + setAppearance(value); + setSavedAppearance(value); + }), + subscriptions.guard((value) => { + setCompact(value); + setSavedCompact(value); + }), + subscriptions.guard((value) => { + setFollowSystemGlass(value); + setSavedSystemGlass(value); + }), + ), + ) + .catch((reason) => { + if (!disposed) setError(String(reason)); + }); if (desktop) - void listen("shortcut-error", (event) => - setError(event.payload), - ).then((stop) => (disposed ? stop() : stops.push(stop))); + void subscriptions + .register("shortcut-error", setError) + .catch((reason) => { + if (!disposed) setError(String(reason)); + }); }); onCleanup(() => { void stopRecording(); disposed = true; - stops.forEach((stop) => stop()); + subscriptions.dispose(); document.removeEventListener("keydown", onKey, true); window.removeEventListener("blur", onBlur); }); diff --git a/src/bridge.ts b/src/bridge.ts index 6fa9694..1e9a5ba 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -82,6 +82,19 @@ export interface SearchResult { } | null; } +export interface LauncherWarning { + code: + | "settingsRead" + | "shortcutRegistration" + | "shortcutsUnavailable" + | "clipboardLimited" + | "trayUnavailable" + | "storageUnavailable" + | "clipboardUnavailable"; + message: string; + retryable: boolean; +} + export interface SearchResponse { preferredSelectionId?: string | null; results: SearchResult[]; @@ -89,7 +102,7 @@ export interface SearchResponse { indexing: boolean; indexError: string | null; notice: string | null; - storageError: string | null; + storageError: LauncherWarning | null; files: FileStatus; currency: CurrencyStatus; } @@ -138,7 +151,7 @@ export interface UpdateStatus { export interface LauncherInfo { settings: SettingsValues; platform: "macos" | "windows" | "linux"; - warnings: string[]; + warnings: LauncherWarning[]; visible?: boolean; initialMode?: SearchMode | null; } diff --git a/src/launcherController.ts b/src/launcherController.ts new file mode 100644 index 0000000..3aea4ed --- /dev/null +++ b/src/launcherController.ts @@ -0,0 +1,164 @@ +import { batch, createSignal } from "solid-js"; +import { createStore, reconcile } from "solid-js/store"; +import type { + CurrencyStatus, + FileStatus, + LauncherInfo, + LauncherWarning, + SettingsValues, + SearchMode, + SearchResponse, + SearchResult, +} from "./bridge"; +import { chooseSelection, createSearchQueue } from "./search"; + +/** Clear only warning categories whose cause a successful settings save repairs. */ +export function receiveLauncherSettings( + current: LauncherInfo, + settings: SettingsValues, +): LauncherInfo { + const shortcutsChanged = + current.settings.shortcut !== settings.shortcut || + JSON.stringify(current.settings.categoryShortcuts) !== + JSON.stringify(settings.categoryShortcuts); + return { + ...current, + settings, + warnings: current.warnings.filter( + (warning) => + warning.code !== "settingsRead" && + !(warning.code === "shortcutRegistration" && shortcutsChanged), + ), + }; +} + +/** Owns the launcher search session, not DOM focus or native command execution. */ +export function createLauncherController(options: { + desktop: boolean; + send: (value: string, mode: SearchMode) => Promise; +}) { + const [visible, setVisible] = createSignal(true); + const [query, setQuery] = createSignal(""); + const [mode, setMode] = createSignal("all"); + const [view, setView] = createStore<{ results: SearchResult[] }>({ + results: [], + }); + const results = () => view.results; + const setResults = (next: SearchResult[]) => + setView("results", reconcile(next, { key: "id" })); + const [selected, setSelected] = createSignal(0); + const current = () => results()[selected()]; + const [pending, setPending] = createSignal(false); + const [total, setTotal] = createSignal(0); + const [indexing, setIndexing] = createSignal(options.desktop); + const [files, setFiles] = createSignal({ + total: 0, + indexing: options.desktop, + warning: null, + }); + const [currency, setCurrency] = createSignal({ + asOf: null, + refreshing: false, + warning: null, + }); + const [error, setError] = createSignal(); + const [indexError, setIndexError] = createSignal(); + const [storageError, setStorageError] = createSignal(); + const [notice, setNotice] = createSignal(); + let disposed = false; + let displayedQuery: { value: string; mode: SearchMode } | undefined; + let selectionChangedByUser = false; + + const searches = createSearchQueue({ + send: ({ value, mode }) => options.send(value, mode), + apply(request, response) { + const index = chooseSelection({ + request, + results: response.results, + preferredSelectionId: response.preferredSelectionId, + displayed: displayedQuery, + current: current(), + selected: selected(), + selectionChangedByUser, + }); + displayedQuery = { value: request.value, mode: request.mode }; + batch(() => { + setResults(response.results); + setSelected(index); + setTotal(response.total); + setIndexing(response.indexing); + setFiles(response.files); + setCurrency(response.currency); + setIndexError(response.indexError ?? undefined); + setStorageError(response.storageError ?? undefined); + setNotice(response.notice ?? undefined); + }); + }, + fail(_request, reason) { + setResults([]); + setError(String(reason)); + }, + settled: () => setPending(false), + }); + + function search(value = query(), preserveSelection = false) { + if (!options.desktop || !visible() || disposed) return Promise.resolve(); + if (!preserveSelection) selectionChangedByUser = false; + setPending(true); + if ( + mode() === "all" && + !value.trim() && + (displayedQuery?.mode !== "all" || displayedQuery.value.trim()) + ) + setResults([]); + setNotice(undefined); + return searches.submit({ value, mode: mode(), preserveSelection }); + } + + function changeQuery(value: string) { + setQuery(value); + setError(undefined); + void search(value); + } + + return { + visible, + setVisible, + query, + setQuery, + mode, + setMode, + results, + selected, + setSelected, + current, + pending, + total, + indexing, + setIndexing, + files, + setFiles, + currency, + error, + setError, + indexError, + storageError, + notice, + setNotice, + search, + changeQuery, + markSelectionChanged() { + selectionChangedByUser = true; + }, + hidden() { + setVisible(false); + // Cancellation invalidates replies; it does not abort Rust work. + searches.cancel(); + setPending(false); + }, + dispose() { + disposed = true; + searches.dispose(); + }, + }; +} diff --git a/src/nativeSubscriptions.ts b/src/nativeSubscriptions.ts new file mode 100644 index 0000000..cbe3923 --- /dev/null +++ b/src/nativeSubscriptions.ts @@ -0,0 +1,43 @@ +type Stop = () => void; +type Listener = ( + name: string, + callback: (event: { payload: unknown }) => void, +) => Promise; + +/** A window owns pending registrations as well as already attached listeners. */ +export function createNativeSubscriptions(listen: Listener) { + let disposed = false; + const stops = new Set(); + async function own(registration: Promise) { + const stop = await registration; + if (disposed) stop(); + else stops.add(stop); + return !disposed; + } + function guard(callback: (value: T) => void) { + return (value: T) => { + if (!disposed) callback(value); + }; + } + return { + own, + guard, + get disposed() { + return disposed; + }, + register(name: string, callback: (payload: T) => void) { + return own( + listen( + name, + guard((event) => callback(event.payload as T)), + ), + ); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const stop of stops) stop(); + stops.clear(); + }, + }; +} diff --git a/src/settingsDraft.ts b/src/settingsDraft.ts new file mode 100644 index 0000000..514c6a0 --- /dev/null +++ b/src/settingsDraft.ts @@ -0,0 +1,73 @@ +import type { SettingsValues } from "./bridge"; +import { normalizeCategories } from "./categories"; + +export type FolderMode = "default" | "custom" | "off"; +export const folderModeFor = (value: SettingsValues): FolderMode => + value.fileSearchRoots === null + ? "default" + : value.fileSearchRoots.length + ? "custom" + : "off"; + +const equal = (a: unknown, b: unknown) => + JSON.stringify(a) === JSON.stringify(b); + +/** Incoming saved values win only where the local draft is unchanged. */ +export function mergeDraft( + previous: T, + draft: T, + incoming: T, +): T { + const merged = { ...incoming }; + const keys = new Set([...Object.keys(previous), ...Object.keys(draft)]); + for (const key of keys as Set) { + if (equal(draft[key], previous[key])) continue; + if (Object.hasOwn(draft, key)) merged[key] = draft[key]; + else delete merged[key]; + } + return merged; +} + +export function mergeSettingsDraft( + previous: SettingsValues | undefined, + current: SettingsValues | undefined, + settings: SettingsValues, + folderMode: FolderMode, +) { + const next = { + ...settings, + visibleCategories: normalizeCategories(settings.visibleCategories), + }; + const merged = + previous && current ? mergeDraft(previous, current, next) : { ...next }; + if (previous && current) { + merged.appPreferences = mergeDraft( + previous.appPreferences, + current.appPreferences, + next.appPreferences, + ); + for (const [id, preference] of Object.entries(current.appPreferences)) { + if (next.appPreferences[id]) { + merged.appPreferences[id] = mergeDraft( + previous.appPreferences[id] ?? { aliases: [], hidden: false }, + preference, + next.appPreferences[id], + ); + } + } + } + return { + saved: next, + draft: merged, + // Incomplete folder text is UI state and must survive unrelated events. + updateFolders: + !previous || + !current || + (equal(current.fileSearchRoots, previous.fileSearchRoots) && + folderMode === folderModeFor(previous)), + updateExcluded: + !previous || + !current || + equal(current.fileSearchExcludedDirs, previous.fileSearchExcludedDirs), + }; +} diff --git a/tests/fixtures/ipc-contract.ts b/tests/fixtures/ipc-contract.ts new file mode 100644 index 0000000..85fc79e --- /dev/null +++ b/tests/fixtures/ipc-contract.ts @@ -0,0 +1,554 @@ +// Generated by serialized_ipc_contracts_match_frontend_fixture. Do not edit. +import type { ContractFixture } from "../ipc-contract"; + +export const contracts = { + "actions": [ + "launch", + "open", + "reveal", + "copy", + "delete", + "run", + "regenerate" + ], + "clipboard": { + "content": "Example", + "createdAt": 1, + "id": 42, + "lastUsedAt": null + }, + "defaults": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + }, + "details": [ + { + "entropyBits": 128, + "strength": "Strong", + "type": "password", + "variant": "password" + }, + { + "ambiguous": false, + "local": "17:00", + "source": "10:00", + "sourceZone": "UTC", + "type": "timezone" + }, + { + "ambiguous": true, + "local": "17:00", + "source": "10:00", + "sourceZone": "UTC", + "targetZone": "Asia/Bangkok", + "type": "timezone" + }, + { + "basedOn": "2026-09-28", + "expression": "tomorrow", + "result": "2026-09-29", + "type": "dateCalculation" + }, + { + "original": "https://example.com/?utm_source=test", + "removed": 1, + "type": "cleanedUrl" + }, + { + "engine": "Example", + "query": "hello world", + "type": "webSearch", + "url": "https://example.com/?q=hello%20world" + } + ], + "fullResult": { + "confirmation": { + "confirmLabel": "Restart", + "description": "Unsaved work can be lost.", + "title": "Restart?" + }, + "detail": { + "entropyBits": 128, + "strength": "Strong", + "type": "password", + "variant": "password" + }, + "icon": "data:image/png;base64,example", + "id": "full", + "kind": "systemCommand", + "path": "/example/file", + "pin": { + "categories": [ + "all", + "apps", + "files", + "emoji", + "calculator", + "clipboard", + "system", + "password", + "timezone", + "url", + "web" + ], + "key": "system:restart" + }, + "primaryAction": "run", + "score": 100, + "secondaryActions": [ + "launch", + "open", + "reveal", + "copy", + "delete", + "run", + "regenerate" + ], + "subtitle": "Confirmation required", + "title": "Restart" + }, + "imported": { + "appearance": "dark", + "compact": true, + "followSystemGlass": null, + "ignoredKeys": [ + "futureKey" + ], + "settings": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + } + }, + "launcher": { + "initialMode": "apps", + "platform": "linux", + "settings": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + }, + "visible": true, + "warnings": [ + { + "code": "settingsRead", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "shortcutRegistration", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "shortcutsUnavailable", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "clipboardLimited", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "trayUnavailable", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "storageUnavailable", + "message": "Fixture warning", + "retryable": false + }, + { + "code": "clipboardUnavailable", + "message": "Fixture warning", + "retryable": false + } + ] + }, + "modes": [ + "all", + "apps", + "files", + "emoji", + "calculator", + "clipboard", + "system", + "password", + "timezone", + "url", + "web" + ], + "response": { + "currency": { + "asOf": null, + "refreshing": false, + "warning": null + }, + "files": { + "indexing": false, + "total": 3, + "warning": null + }, + "indexError": null, + "indexing": false, + "notice": null, + "preferredSelectionId": null, + "results": [ + { + "icon": null, + "id": "result:0", + "kind": "app", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:1", + "kind": "file", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:2", + "kind": "folder", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:3", + "kind": "calculation", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:4", + "kind": "emoji", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:5", + "kind": "clipboard", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:6", + "kind": "systemCommand", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:7", + "kind": "password", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:8", + "kind": "timezone", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:9", + "kind": "cleanedUrl", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + }, + { + "icon": null, + "id": "result:10", + "kind": "webSearch", + "primaryAction": "copy", + "score": 10, + "secondaryActions": [], + "subtitle": "Wire example", + "title": "Fixture" + } + ], + "storageError": null, + "total": 11 + }, + "settings": { + "appPreferences": { + "app:example": { + "aliases": [ + "editor" + ], + "hidden": true + } + }, + "categoryShortcuts": [ + { + "mode": "apps", + "shortcut": "Control+Shift+KeyA" + } + ], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": [ + "/example" + ], + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [ + { + "enabled": true, + "keyword": "ex", + "name": "Example", + "template": "https://example.com/?q={query}" + } + ] + }, + "settingsInfo": { + "configPath": "/example/settings.json", + "dataPath": "/example/tinydash.sqlite3", + "defaults": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + }, + "platform": "linux", + "settings": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + }, + "shortcutsAvailable": true, + "version": "0.1.3" + }, + "update": { + "available": true, + "message": "Update available", + "notes": null, + "version": "0.2.0" + }, + "warningResponse": { + "currency": { + "asOf": "2026-09-28", + "refreshing": true, + "warning": "Rates are old" + }, + "files": { + "indexing": true, + "total": 1, + "warning": "Scan incomplete" + }, + "indexError": "Index unavailable", + "indexing": true, + "notice": "Try a shorter query", + "preferredSelectionId": "clipboard:1", + "results": [], + "storageError": { + "code": "storageUnavailable", + "message": "Storage unavailable", + "retryable": false + }, + "total": 0 + } +} satisfies ContractFixture; diff --git a/tests/ipc-contract.spec.ts b/tests/ipc-contract.spec.ts new file mode 100644 index 0000000..af79893 --- /dev/null +++ b/tests/ipc-contract.spec.ts @@ -0,0 +1,184 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import type { + Action, + LauncherWarning, + SearchMode, + SearchResult, +} from "../src/bridge"; +import { contracts } from "./fixtures/ipc-contract"; + +// A new TS variant also needs a serialized Rust example, not just a widened union. +type MissingExamples = + | Exclude + | Exclude + | Exclude< + SearchResult["kind"], + (typeof contracts.response.results)[number]["kind"] + > + | Exclude< + NonNullable["type"], + (typeof contracts.details)[number]["type"] + > + | Exclude< + LauncherWarning["code"], + (typeof contracts.launcher.warnings)[number]["code"] + >; +const complete: [MissingExamples] extends [never] ? true : never = true; +const root = resolve(import.meta.dirname, ".."); +const rust = (path: string) => + readFileSync(resolve(root, "src-tauri/src", path), "utf8"); +const camelCase = (name: string) => + name.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()); + +function variants(path: string, name: string) { + const body = rust(path).split(`pub enum ${name} {`)[1]?.split("\n}")[0]; + expect(body, `Missing Rust enum ${name}`).toBeDefined(); + return [...body.matchAll(/^ (\w+)(?:,| \{)/gm)] + .map((match) => match[1][0].toLowerCase() + match[1].slice(1)) + .sort(); +} + +test("canonical serde fixtures cover every variant and optional wire shape", () => { + expect(complete).toBe(true); + expect([...contracts.actions].sort()).toEqual( + variants("launcher/result.rs", "Action"), + ); + expect([...contracts.modes].sort()).toEqual( + variants("launcher/query.rs", "SearchMode"), + ); + expect( + contracts.response.results.map((result) => result.kind).sort(), + ).toEqual(variants("launcher/result.rs", "ResultKind")); + expect( + [...new Set(contracts.details.map((detail) => detail.type))].sort(), + ).toEqual(variants("launcher/result.rs", "ToolDetail")); + expect( + contracts.launcher.warnings.map((warning) => warning.code).sort(), + ).toEqual(variants("launcher/warning.rs", "WarningCode")); + expect(contracts.response.results[0]).not.toHaveProperty("path"); + expect(contracts.response.results[0]).not.toHaveProperty("detail"); + expect(contracts.response.results[0].icon).toBeNull(); + expect(contracts.fullResult).toHaveProperty( + "confirmation.confirmLabel", + "Restart", + ); + expect(contracts.fullResult.pin.categories).toEqual(contracts.modes); + expect(contracts.defaults.fileSearchRoots).toBeNull(); + expect(contracts.settings.fileSearchRoots).toEqual(["/example"]); + expect(contracts.warningResponse.storageError).toEqual({ + code: "storageUnavailable", + message: "Storage unavailable", + retryable: false, + }); +}); + +function commands() { + const registrations = rust("lib.rs") + .split("tauri::generate_handler![")[1] + ?.split("]")[0]; + expect(registrations).toBeDefined(); + const names = registrations + .split(",") + .map((name) => name.trim().split("::").at(-1)!) + .filter(Boolean) + .sort(); + const signatures = new Map(); + const paths = readdirSync(resolve(root, "src-tauri/src/launcher"), { + recursive: true, + }).filter((path) => typeof path === "string" && path.endsWith(".rs")); + for (const path of paths) { + const source = rust(`launcher/${path}`); + // These internal commands use simple owned parameters. Fail closed when a + // new signature is not understood instead of silently skipping the command. + for (const match of source.matchAll( + /#\[tauri::command\]\s*pub (?:async )?fn (\w+)\(([^)]*)\)/g, + )) { + const args = match[2] + .split(",") + .map((arg) => arg.trim()) + .filter(Boolean) + .map((arg) => { + const parameter = /^(\w+):\s*(.+)$/.exec(arg); + if (!parameter) + throw new Error(`Unrecognized command parameter: ${arg}`); + return { name: camelCase(parameter[1]), type: parameter[2] }; + }) + .filter((arg) => !["AppHandle", "WebviewWindow"].includes(arg.type)); + signatures.set(match[1], args); + } + } + expect([...signatures.keys()].sort()).toEqual(names); + return { names, signatures }; +} + +test("every bridge wrapper invokes a registered Rust command with matching argument names and types", async ({ + page, +}) => { + const { names, signatures } = commands(); + await page.goto("/"); + const calls = await page.evaluate(async (settings) => { + const path = "/tests/ipc-probe.ts"; + const { probeCommands } = (await import( + path + )) as typeof import("./ipc-probe"); + return probeCommands(settings); + }, contracts.settings); + expect([...new Set(calls.map((call) => call.command))].sort()).toEqual(names); + for (const { command, args } of calls) { + const signature = signatures.get(command)!; + const required = signature.filter((arg) => !arg.type.startsWith("Option<")); + expect( + Object.keys(args).filter( + (name) => !signature.some((arg) => arg.name === name), + ), + command, + ).toEqual([]); + for (const arg of required) expect(args, command).toHaveProperty(arg.name); + for (const [name, value] of Object.entries(args)) { + const type = signature + .find((arg) => arg.name === name)! + .type.replace(/^Option<(.+)>$/, "$1"); + switch (type) { + case "String": + expect(typeof value, `${command}.${name}`).toBe("string"); + break; + case "bool": + expect(typeof value, `${command}.${name}`).toBe("boolean"); + break; + case "Vec": + expect( + Array.isArray(value) && + value.every((item) => typeof item === "string"), + ).toBe(true); + break; + case "SearchMode": + expect(contracts.modes).toContain(value); + break; + case "Action": + expect(contracts.actions).toContain(value); + break; + case "Settings": + expect(value).toEqual(contracts.settings); + break; + case "settings::WebSearch": + expect(value).toEqual(contracts.settings.webSearches[0]); + break; + case "LauncherAppearance": + expect(value).toBe("dark"); + break; + default: + throw new Error(`Add coverage for ${command}.${name}: ${type}`); + } + } + } + expect( + calls + .filter((call) => call.command === "execute_action") + .map((call) => call.args), + ).toEqual([ + { id: "app:example", action: "launch", confirmed: true }, + { id: "app:example", action: "launch" }, + ]); +}); diff --git a/tests/ipc-contract.ts b/tests/ipc-contract.ts new file mode 100644 index 0000000..993e3b3 --- /dev/null +++ b/tests/ipc-contract.ts @@ -0,0 +1,29 @@ +import type { + Action, + ClipboardEntry, + LauncherInfo, + SearchMode, + SearchResponse, + SearchResult, + SettingsImport, + SettingsInfo, + SettingsValues, + UpdateStatus, +} from "../src/bridge"; + +/** Compile-time consumer of the Rust-serialized examples; no runtime validator/SDK. */ +export interface ContractFixture { + actions: Action[]; + modes: SearchMode[]; + response: SearchResponse; + warningResponse: SearchResponse; + fullResult: SearchResult; + details: NonNullable[]; + settings: SettingsValues; + defaults: SettingsValues; + clipboard: ClipboardEntry; + imported: SettingsImport; + update: UpdateStatus; + launcher: LauncherInfo; + settingsInfo: SettingsInfo; +} diff --git a/tests/ipc-probe.ts b/tests/ipc-probe.ts new file mode 100644 index 0000000..d8f1d56 --- /dev/null +++ b/tests/ipc-probe.ts @@ -0,0 +1,52 @@ +import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"; +import { backend, type SettingsValues } from "../src/bridge"; + +/** Exercise the real bridge wrappers without starting the native application. */ +export async function probeCommands(settings: SettingsValues) { + const calls: { command: string; args: Record }[] = []; + mockIPC((command, args) => { + calls.push({ command, args: (args ?? {}) as Record }); + }); + const probes = { + setLauncherAppearance: () => backend.setLauncherAppearance("dark"), + ready: backend.ready, + openSettings: backend.openSettings, + settings: backend.settings, + saveSettings: () => backend.saveSettings(settings), + chooseClipboardHistory: () => backend.chooseClipboardHistory(true), + appCatalog: backend.appCatalog, + setAppPreference: () => + backend.setAppPreference("app:example", ["alias"], true), + previewWebSearch: () => + backend.previewWebSearch(settings.webSearches[0], "query"), + exportSettings: () => backend.exportSettings("dark", true, false), + importSettings: backend.importSettings, + revealBackup: backend.revealBackup, + checkUpdate: backend.checkUpdate, + installUpdate: backend.installUpdate, + recordShortcut: () => backend.recordShortcut(true), + revealSettings: () => backend.revealSettings(true), + search: () => backend.search("fixture", "all"), + setPinned: () => backend.setPinned("app:example", "apps", true), + execute: () => backend.execute("app:example", "launch", true), + clipboardPreview: () => backend.clipboardPreview("clipboard:1"), + clearClipboard: () => backend.clearClipboard(true), + editClipboardCopy: () => backend.editClipboardCopy("clipboard:1", "text"), + copyClipboardSelection: () => + backend.copyClipboardSelection(["clipboard:1"], "\n"), + saveClipboardFile: () => backend.saveClipboardFile("clipboard:1"), + hide: backend.hide, + resetPosition: backend.resetPosition, + refresh: backend.refresh, + refreshFiles: backend.refreshFiles, + refreshCurrency: backend.refreshCurrency, + quit: backend.quit, + } satisfies Record Promise>; + try { + for (const probe of Object.values(probes)) await probe(); + await backend.execute("app:example", "launch"); + return calls; + } finally { + clearMocks(); + } +} diff --git a/tests/launcher-controller.spec.ts b/tests/launcher-controller.spec.ts new file mode 100644 index 0000000..a7cbbcd --- /dev/null +++ b/tests/launcher-controller.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from "@playwright/test"; +import type { SearchResponse } from "../src/bridge"; +import { contracts } from "./fixtures/ipc-contract"; + +// Run standalone Solid state owners under the browser export condition, without +// rendering App. Node's Solid server build intentionally does not update stores. +test("controller reconciles rows and preserves user selection only for refreshes", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + let reply = response; + const controller = createLauncherController({ + desktop: true, + send: async () => reply, + }); + controller.setQuery("fixture"); + await controller.search(); + controller.setSelected(1); + controller.markSelectionChanged(); + const selected = controller.current(); + reply = { + ...response, + results: [response.results[1], response.results[0]], + notice: "Refreshed", + }; + await controller.search("fixture", true); + const refreshed = { + id: controller.current().id, + index: controller.selected(), + sameRow: selected === controller.current(), + pending: controller.pending(), + notice: controller.notice(), + }; + controller.setQuery("new query"); + reply = response; + await controller.search(); + const newQuery = controller.current().id; + controller.dispose(); + return { refreshed, newQuery }; + }, contracts.response); + expect(result).toEqual({ + refreshed: { + id: "result:1", + index: 0, + sameRow: true, + pending: false, + notice: "Refreshed", + }, + newQuery: "result:0", + }); +}); + +test("hidden and disposed controllers reject late replies and do not send hidden searches", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + let complete!: (response: SearchResponse) => void; + let calls = 0; + const controller = createLauncherController({ + desktop: true, + send: () => { + calls++; + return new Promise((resolve) => { + complete = resolve; + }); + }, + }); + const hidden = controller.search("hidden"); + controller.hidden(); + await controller.search("not sent"); + complete(response); + await hidden; + const afterHidden = { + count: controller.results().length, + pending: controller.pending(), + calls, + }; + controller.setVisible(true); + const reopened = controller.search("reopened"); + complete(response); + await reopened; + const reopenedCount = controller.results().length; + const closing = controller.search("closing"); + controller.dispose(); + complete({ ...response, results: [] }); + await closing; + await controller.search("disposed"); + return { + afterHidden, + reopenedCount, + finalCount: controller.results().length, + calls, + }; + }, contracts.response); + expect(result).toEqual({ + afterHidden: { count: 0, pending: false, calls: 1 }, + reopenedCount: 11, + finalCount: 11, + calls: 3, + }); +}); + +test("controller clears stale welcome rows, reports failures, and gates non-desktop search", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + let fail = false; + const send = async () => { + if (fail) throw new Error("Search unavailable"); + return response; + }; + const controller = createLauncherController({ desktop: true, send }); + controller.setQuery("fixture"); + await controller.search(); + fail = true; + controller.setQuery(""); + const pending = controller.search(); + const clearedImmediately = controller.results().length; + await pending; + const failed = { + message: controller.error(), + pending: controller.pending(), + }; + const preview = createLauncherController({ + desktop: false, + send: () => { + throw new Error("must not send"); + }, + }); + await preview.search(); + const previewPending = preview.pending(); + controller.dispose(); + preview.dispose(); + return { clearedImmediately, failed, previewPending }; + }, contracts.response); + expect(result).toEqual({ + clearedImmediately: 0, + failed: { message: "Error: Search unavailable", pending: false }, + previewPending: false, + }); +}); diff --git a/tests/launcher-warnings.spec.ts b/tests/launcher-warnings.spec.ts new file mode 100644 index 0000000..40702d7 --- /dev/null +++ b/tests/launcher-warnings.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import type { LauncherInfo, LauncherWarning } from "../src/bridge"; +import { receiveLauncherSettings } from "../src/launcherController"; +import { contracts } from "./fixtures/ipc-contract"; +import type {} from "./mock-backend"; + +const warnings: LauncherWarning[] = [ + { code: "settingsRead", message: "Paramètres illisibles", retryable: false }, + { + code: "shortcutRegistration", + message: "Raccourci occupé", + retryable: true, + }, + // Deliberately resemble the OLD English prefix: code, not prose, is authority. + { + code: "trayUnavailable", + message: "Could not register tray icon", + retryable: false, + }, + { + code: "shortcutsUnavailable", + message: "Shortcut service unavailable", + retryable: false, + }, +]; + +test("settings warnings clear by code while unrelated and unrepaired warnings survive", () => { + const info: LauncherInfo = { ...contracts.launcher, warnings }; + const unchanged = receiveLauncherSettings(info, info.settings); + expect(unchanged.warnings.map((warning) => warning.code)).toEqual([ + "shortcutRegistration", + "trayUnavailable", + "shortcutsUnavailable", + ]); + const changed = receiveLauncherSettings(info, { + ...info.settings, + shortcut: "Control+KeyT", + }); + expect(changed.warnings.map((warning) => warning.code)).toEqual([ + "trayUnavailable", + "shortcutsUnavailable", + ]); + const categoryChanged = receiveLauncherSettings(info, { + ...info.settings, + categoryShortcuts: [{ mode: "apps", shortcut: "Control+KeyA" }], + }); + expect(categoryChanged.warnings).toEqual(changed.warnings); + expect(info.warnings).toEqual(warnings); +}); + +test("launcher displays structured messages and clears only repaired warning categories", async ({ + page, +}) => { + await page.route( + (url) => url.pathname === "/src/index.tsx", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + body: `import "/tests/mock-backend.ts";\nwindow.__launcherTest.warnings = ${JSON.stringify(warnings)};\n${await response.text()}`, + }); + }, + ); + await page.goto("/"); + await expect(page.getByRole("alert")).toHaveText("Paramètres illisibles"); + await page.evaluate(() => + window.__launcherTest.emit( + "settings-changed", + window.__launcherTest.settings, + ), + ); + await expect(page.getByRole("alert")).toHaveText("Raccourci occupé"); + await page.evaluate(() => { + window.__launcherTest.settings = { + ...window.__launcherTest.settings, + shortcut: "Control+KeyT", + }; + return window.__launcherTest.emit( + "settings-changed", + window.__launcherTest.settings, + ); + }); + await expect(page.getByRole("alert")).toHaveText( + "Could not register tray icon", + ); + await page + .getByRole("navigation", { name: "Search categories" }) + .getByRole("button", { name: "Apps", exact: true }) + .click(); + await expect(page.getByRole("option").first()).toBeVisible(); + await page.evaluate(() => { + window.__launcherTest.storageError = "Local storage is busy. Try again."; + return window.__launcherTest.emit("usage-changed", null); + }); + await expect(page.getByRole("alert")).toHaveText( + "Local storage is busy. Try again.", + ); + await expect(page.getByRole("option").first()).toBeVisible(); + await expect( + page.getByRole("button", { name: "Retry", exact: true }), + ).toHaveCount(0); +}); diff --git a/tests/mock-backend.ts b/tests/mock-backend.ts index 60aa565..f1edf5a 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -4,6 +4,7 @@ import { emit } from "@tauri-apps/api/event"; import type { SearchResult, SearchMode, + LauncherWarning, SettingsImport, SettingsValues, UpdateStatus, @@ -29,6 +30,7 @@ declare global { toolRevision: number; rejectActions: boolean | string; storageError: string | null; + warnings: LauncherWarning[]; usedAppFirst: boolean; clipboardDeleted: string[]; clipboardCleared: boolean; @@ -317,6 +319,7 @@ window.__launcherTest = { toolRevision: 0, rejectActions: false, storageError: null, + warnings: [], usedAppFirst: false, clipboardDeleted: [], clipboardCleared: false, @@ -375,7 +378,7 @@ mockIPC( return { platform: state.platform, settings: state.settings, - warnings: [], + warnings: state.warnings, visible: state.initialVisible, initialMode: state.initialMode, }; @@ -616,7 +619,13 @@ mockIPC( : query === "=1 / 0" ? "Division by zero is not allowed." : null, - storageError: state.storageError, + storageError: state.storageError + ? { + code: "storageUnavailable", + message: state.storageError, + retryable: false, + } + : null, currency: { asOf: state.currencyDate, refreshing: state.currencyRefreshing, diff --git a/tests/native-subscriptions.spec.ts b/tests/native-subscriptions.spec.ts new file mode 100644 index 0000000..adfaea0 --- /dev/null +++ b/tests/native-subscriptions.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { createNativeSubscriptions } from "../src/nativeSubscriptions"; + +function harness() { + const pending: { + callback: (event: { payload: unknown }) => void; + resolve: (stop: () => void) => void; + reject: (reason: unknown) => void; + }[] = []; + const owner = createNativeSubscriptions( + (_name, callback) => + new Promise((resolve, reject) => { + pending.push({ callback, resolve, reject }); + }), + ); + return { owner, pending }; +} + +test("owns late native registrations and suppresses events after disposal", async () => { + const { owner, pending } = harness(); + const values: unknown[] = []; + let stopped = 0; + const registration = owner.register("settings-changed", (value) => + values.push(value), + ); + pending[0].callback({ payload: "before" }); + owner.dispose(); + pending[0].callback({ payload: "after" }); + pending[0].resolve(() => stopped++); + expect(await registration).toBe(false); + owner.dispose(); + expect(stopped).toBe(1); + expect(values).toEqual(["before"]); +}); + +test("cleans successful subscriptions when a sibling registration fails", async () => { + const { owner, pending } = harness(); + let stopped = 0; + const first = owner.register("apps-changed", () => {}); + const second = owner.register("files-changed", () => {}); + pending[0].resolve(() => stopped++); + expect(await first).toBe(true); + pending[1].reject(new Error("listener unavailable")); + await expect(second).rejects.toThrow("listener unavailable"); + owner.dispose(); + expect(stopped).toBe(1); +}); + +test("appearance subscriptions share the same asynchronous lifetime", async () => { + const { owner } = harness(); + let stopped = 0; + let changed = 0; + const callback = owner.guard(() => changed++); + let resolve!: (stop: () => void) => void; + const active = owner.own( + new Promise((done) => { + resolve = done; + }), + ); + owner.dispose(); + callback(undefined); + resolve(() => stopped++); + expect(await active).toBe(false); + expect({ stopped, changed }).toEqual({ stopped: 1, changed: 0 }); +}); diff --git a/tests/settings-draft.spec.ts b/tests/settings-draft.spec.ts new file mode 100644 index 0000000..23f158d --- /dev/null +++ b/tests/settings-draft.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import type { SettingsValues } from "../src/bridge"; +import { mergeDraft, mergeSettingsDraft } from "../src/settingsDraft"; +import { contracts } from "./fixtures/ipc-contract"; + +const settings = (): SettingsValues => structuredClone(contracts.settings); + +test("merges incoming settings without losing local changes or mutating inputs", () => { + const saved = settings(); + const draft = settings(); + const incoming = settings(); + draft.shortcut = "Control+Shift+KeyB"; + draft.appPreferences["app:example"].aliases = ["local"]; + incoming.hideOnBlur = false; + incoming.appPreferences["app:example"].hidden = false; + incoming.appPreferences["app:new"] = { aliases: [], hidden: true }; + const before = structuredClone({ saved, draft, incoming }); + const result = mergeSettingsDraft(saved, draft, incoming, "custom"); + expect(result.draft.shortcut).toBe(draft.shortcut); + expect(result.draft.hideOnBlur).toBe(false); + expect(result.draft.appPreferences).toEqual({ + "app:example": { aliases: ["local"], hidden: false }, + "app:new": { aliases: [], hidden: true }, + }); + expect(result.saved).toEqual(incoming); + expect({ saved, draft, incoming }).toEqual(before); +}); + +test("merges local deletions and additions while accepting remote deletions", () => { + expect( + mergeDraft({ a: 1, b: 1 }, { b: 2, c: 3 }, { a: 2, b: 3, d: 4 }), + ).toEqual({ b: 2, c: 3, d: 4 }); + const saved = settings(); + const incoming = settings(); + incoming.appPreferences = {}; + expect( + mergeSettingsDraft(saved, settings(), incoming, "custom").draft + .appPreferences, + ).toEqual({}); + const draft = settings(); + draft.appPreferences = {}; + expect( + mergeSettingsDraft(saved, draft, settings(), "custom").draft.appPreferences, + ).toEqual({}); +}); + +test("preserves incomplete folder mode and locally edited exclusion text", () => { + const saved = settings(); + saved.fileSearchRoots = null; + const draft = structuredClone(saved); + draft.fileSearchExcludedDirs = ["local", "unfinished"]; + const incoming = settings(); + incoming.fileSearchExcludedDirs = ["remote"]; + const result = mergeSettingsDraft(saved, draft, incoming, "custom"); + expect(result.updateFolders).toBe(false); + expect(result.updateExcluded).toBe(false); + expect(result.draft.fileSearchExcludedDirs).toEqual(["local", "unfinished"]); + const untouched = mergeSettingsDraft(saved, saved, incoming, "default"); + expect(untouched.updateFolders).toBe(true); + expect(untouched.updateExcluded).toBe(true); + expect(untouched.draft.fileSearchRoots).toEqual(["/example"]); +}); + +test("first load normalizes categories and resets text baselines", () => { + const incoming = settings(); + incoming.visibleCategories = ["apps", "apps", "files"]; + const result = mergeSettingsDraft(undefined, undefined, incoming, "off"); + expect(result.draft.visibleCategories).toEqual(["apps", "files"]); + expect(result.updateFolders).toBe(true); + expect(result.updateExcluded).toBe(true); +}); From 86940f7ea19e63e693527128ff01c08ee0ba0ca7 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 13:07:16 +0800 Subject: [PATCH 2/5] fix(frontend): enforce exact wire contracts and merge remote defaults --- .prettierignore | 2 + docs/how-to/verify.md | 12 +- docs/reference/features/launcher.md | 4 +- docs/reference/features/settings.md | 2 +- scripts/perf/file-index/build.rs | 1 + src-tauri/Cargo.lock | 33 ++ src-tauri/Cargo.toml | 4 + src-tauri/src/launcher/contract_tests.rs | 5 +- src-tauri/src/launcher/currency.rs | 1 + src-tauri/src/launcher/files.rs | 1 + src-tauri/src/launcher/mod.rs | 4 + src-tauri/src/launcher/pins.rs | 1 + src-tauri/src/launcher/portability.rs | 1 + src-tauri/src/launcher/preferences.rs | 1 + src-tauri/src/launcher/query.rs | 1 + src-tauri/src/launcher/result.rs | 11 + src-tauri/src/launcher/updates.rs | 1 + src-tauri/src/launcher/warning.rs | 2 + src-tauri/src/launcher/window.rs | 1 + src-tauri/src/launcher/wire_types_tests.rs | 367 +++++++++++++++++++++ src-tauri/src/providers/clipboard.rs | 1 + src-tauri/src/settings.rs | 4 + src/bridge.ts | 16 +- src/settingsDraft.ts | 9 +- tests/fixtures/ipc-contract.ts | 53 ++- tests/fixtures/ipc-wire.ts | 60 ++++ tests/ipc-contract.spec.ts | 14 +- tests/ipc-contract.ts | 3 + tests/ipc-drift.spec.ts | 176 ++++++++++ tests/ipc-probe.ts | 19 +- tests/ipc-types.ts | 94 ++++++ tests/launcher-controller.spec.ts | 63 ++++ tests/mock-backend.ts | 2 +- tests/settings-draft.spec.ts | 17 + tests/settings.spec.ts | 40 +++ 35 files changed, 1001 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/launcher/wire_types_tests.rs create mode 100644 tests/fixtures/ipc-wire.ts create mode 100644 tests/ipc-drift.spec.ts create mode 100644 tests/ipc-types.ts diff --git a/.prettierignore b/.prettierignore index aae214e..f38916c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,5 @@ designs site-dist # Canonical serde output, compared byte-for-byte by Rust contract tests. tests/fixtures/ipc-contract.ts +# Test-only ts-rs declarations and parsed Rust command signatures. +tests/fixtures/ipc-wire.ts diff --git a/docs/how-to/verify.md b/docs/how-to/verify.md index a08cbcf..ea6c9fd 100644 --- a/docs/how-to/verify.md +++ b/docs/how-to/verify.md @@ -45,18 +45,22 @@ Use focused tests during diagnosis. After the final relevant edit, repeat affect ## Check the internal IPC contract -`verify:full` checks the actual Rust serialization against [canonical fixtures](../../tests/fixtures/ipc-contract.ts), type-checks those fixtures against the frontend bridge, and checks real bridge invocations against Rust command registration and argument names/types. Result kinds, actions, search modes, tool details, settings, and targeted structured warnings are covered. These are internal contracts, not a public SDK. Command errors that are only displayed can still be strings. +`verify:full` compares actual Rust serialization with [canonical examples](../../tests/fixtures/ipc-contract.ts) and test-only `ts-rs` declarations with [checked wire types](../../tests/fixtures/ipc-wire.ts). Recursive TypeScript equality checks reject extra optional keys, missing keys, changed optionality, and narrowed or widened nullable/enum domains. Rust command signatures are parsed with test-only `syn`; the bridge's argument domains and success returns must match. Runtime probes independently check each wrapper's command binding, registration, argument names, and representative values. + +The bridge represents Rust `Option` arguments by omission instead of explicit null; `revealSettings` additionally supplies a default for Rust's required boolean. These are explicit test adaptations, not general assignability exceptions. Serialized response keys remain required unless serde omits them. Platform fields are strings because Rust currently declares strings, not a platform enum. Unsupported serialization rules and command types require review rather than silently generating a partial contract. Conditional `Option::is_none` serialization has a checked test-only `ts(optional)` annotation. + +These are internal contracts, not a public SDK or runtime validator. Command errors that are only displayed remain strings. [Drift tests](../../tests/ipc-drift.spec.ts) compile isolated mutations and require contract-specific failures; they do not modify the checkout. When intentionally changing the wire contract, regenerate its examples, review the diff, and run the normal checks: ```sh -TINYDASH_UPDATE_CONTRACTS=1 bun run test:rust -- serialized_ipc_contracts_match_frontend_fixture +TINYDASH_UPDATE_CONTRACTS=1 bun run test:rust -- ipc_ bun run typecheck -bun run verify:browser tests/ipc-contract.spec.ts +bun run verify:browser tests/ipc-contract.spec.ts tests/ipc-drift.spec.ts bun run verify:full ``` -Do not set `TINYDASH_UPDATE_CONTRACTS` in CI. The normal Rust test compares the fixture byte-for-byte and does not update it. The fixture is excluded from Prettier because serde owns its formatting. Add representative samples for new optional fields or variants; the contract tests require every result, detail, action, mode, and warning-code variant to have an example. Changes to a command signature may require a new probe or supported argument type in the command consistency test. +Do not set `TINYDASH_UPDATE_CONTRACTS` in CI. Normal Rust tests compare both generated files byte-for-byte without updating them; both are excluded from Prettier. Add representative samples for new optional fields or variants, including present and null cases. Examples complement, but do not replace, exact shape checks. Changes to a command signature may require a new wrapper binding, probe, or supported type in the command consistency tests. `ts-rs` and `syn` are dev-dependencies only; neither ships in the application. These checks prove serialization and bridge consistency, not native IPC authorization or OS effects. Use the desktop procedures for those. diff --git a/docs/reference/features/launcher.md b/docs/reference/features/launcher.md index f69886b..f4230d5 100644 --- a/docs/reference/features/launcher.md +++ b/docs/reference/features/launcher.md @@ -18,10 +18,10 @@ Run this browser recipe from the repository root. It retains successful traces i ```sh bun run verify:browser tests/welcome.spec.ts tests/categories.spec.ts tests/launcher.spec.ts -bun run verify:browser tests/launcher-controller.spec.ts tests/native-subscriptions.spec.ts tests/launcher-warnings.spec.ts tests/ipc-contract.spec.ts +bun run verify:browser tests/launcher-controller.spec.ts tests/native-subscriptions.spec.ts tests/launcher-warnings.spec.ts tests/ipc-contract.spec.ts tests/ipc-drift.spec.ts ``` -The standalone controller checks cover result reconciliation, refresh selection, hidden/disposed replies, failure state, and browser-preview gating. Subscription checks cover late registrations and teardown. Warning tests use changed display text to prove that settings repair follows structured codes rather than English prefixes; unrelated platform warnings remain visible. A storage warning must leave search results usable. +The standalone controller checks cover result reconciliation, refresh selection, hidden/disposed replies, reopening before an old reply settles (the original single-flight queue must ignore the old result and keep the new search pending), failure state, and browser-preview gating. Subscription checks cover late registrations and teardown. Warning tests use changed display text to prove that settings repair follows structured codes rather than English prefixes; unrelated platform warnings remain visible. A storage warning must leave search results usable. For affected backend behavior: diff --git a/docs/reference/features/settings.md b/docs/reference/features/settings.md index 96d8b55..63316e0 100644 --- a/docs/reference/features/settings.md +++ b/docs/reference/features/settings.md @@ -23,7 +23,7 @@ bun run verify:browser tests/settings.spec.ts tests/tinycast-features.spec.ts te bun run verify:browser tests/settings-draft.spec.ts tests/native-subscriptions.spec.ts ``` -Standalone draft tests check local edits against incoming saved values, nested application preferences, deletions, category normalization, and incomplete folder input. Browser tests additionally exercise clean and dirty forms receiving changes from another window. A saved-settings event must not discard unrelated local edits. +Standalone draft tests check local edits against incoming saved values, nested application preferences, remote deletion of default preferences while aliases are dirty, local deletions, category normalization, and incomplete folder input. Browser tests additionally exercise clean and dirty forms receiving changes from another window. A saved-settings event must not discard unrelated local edits. For affected backend behavior: diff --git a/scripts/perf/file-index/build.rs b/scripts/perf/file-index/build.rs index 583b70b..9d4f84e 100644 --- a/scripts/perf/file-index/build.rs +++ b/scripts/perf/file-index/build.rs @@ -16,6 +16,7 @@ fn main() { ("result", "src-tauri/src/launcher/result.rs"), ("query", "src-tauri/src/launcher/query.rs"), ("pins", "src-tauri/src/launcher/pins.rs"), + ("warning", "src-tauri/src/launcher/warning.rs"), ]; let mut modules = String::new(); for (name, relative) in files { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c638dc2..3190ca6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4645,6 +4645,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4765,6 +4774,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "syn 2.0.119", "tar", "tauri", "tauri-build", @@ -4779,6 +4789,7 @@ dependencies = [ "thiserror 2.0.20", "tracing", "tracing-subscriber", + "ts-rs", "unicode-normalization", "url", "walkdir", @@ -5104,6 +5115,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.20", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d19a7c..9bd881b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,10 @@ gio-unix = "0.22.8" # Tauri requires GTK 0.18. GTK 0.19 conflicts over the native `gtk-3` link. gtk = "0.18.2" +[dev-dependencies] +ts-rs = { version = "12", features = ["no-serde-warnings"] } +syn = { version = "2", features = ["full", "visit"] } + [profile.release] strip = true lto = "thin" diff --git a/src-tauri/src/launcher/contract_tests.rs b/src-tauri/src/launcher/contract_tests.rs index 4c27df7..a008873 100644 --- a/src-tauri/src/launcher/contract_tests.rs +++ b/src-tauri/src/launcher/contract_tests.rs @@ -194,8 +194,11 @@ fn serialized_ipc_contracts_match_frontend_fixture() { "launcher": crate::launcher::contract_launcher_info(), "settingsInfo": crate::launcher::preferences::contract_settings_info(), "clipboard": crate::providers::clipboard::ClipboardEntry { id: 42, content: "Example".into(), created_at: 1, last_used_at: None }, + "usedClipboard": crate::providers::clipboard::ClipboardEntry { id: 42, content: "Used".into(), created_at: 1, last_used_at: Some(2) }, "imported": crate::launcher::portability::SettingsImport { settings: Settings::default(), ignored_keys: vec!["futureKey".into()], appearance: Some("dark".into()), compact: Some(true), follow_system_glass: None }, - "update": crate::launcher::updates::UpdateStatus { available: true, version: Some("0.2.0".into()), notes: None, message: "Update available".into() }, + "importedDefaults": crate::launcher::portability::SettingsImport { settings: Settings::default(), ignored_keys: vec![], appearance: None, compact: None, follow_system_glass: Some(true) }, + "update": crate::launcher::updates::UpdateStatus { available: true, version: Some("0.2.0".into()), notes: Some("Release notes".into()), message: "Update available".into() }, + "noUpdate": crate::launcher::updates::UpdateStatus { available: false, version: None, notes: None, message: "Up to date".into() }, }); let source = format!( "// Generated by serialized_ipc_contracts_match_frontend_fixture. Do not edit.\nimport type {{ ContractFixture }} from \"../ipc-contract\";\n\nexport const contracts = {} satisfies ContractFixture;\n", diff --git a/src-tauri/src/launcher/currency.rs b/src-tauri/src/launcher/currency.rs index 3c39992..d665009 100644 --- a/src-tauri/src/launcher/currency.rs +++ b/src-tauri/src/launcher/currency.rs @@ -45,6 +45,7 @@ pub struct Currency(Mutex); #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct CurrencyStatus { pub as_of: Option, pub refreshing: bool, diff --git a/src-tauri/src/launcher/files.rs b/src-tauri/src/launcher/files.rs index 9839497..974be9a 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -30,6 +30,7 @@ pub struct FileScan { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct FileStatus { pub total: usize, pub indexing: bool, diff --git a/src-tauri/src/launcher/mod.rs b/src-tauri/src/launcher/mod.rs index bedebb2..1f6c28d 100644 --- a/src-tauri/src/launcher/mod.rs +++ b/src-tauri/src/launcher/mod.rs @@ -35,6 +35,9 @@ use result::SearchResponse; use search::SearchManager; use warning::{LauncherWarning, WarningCode}; +#[cfg(test)] +mod wire_types_tests; + pub struct LauncherState { pub search: Mutex, pub scanning: AtomicBool, @@ -123,6 +126,7 @@ impl LauncherState { #[derive(Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct LauncherInfo { settings: Settings, platform: &'static str, diff --git a/src-tauri/src/launcher/pins.rs b/src-tauri/src/launcher/pins.rs index 62b00c9..ca28bd1 100644 --- a/src-tauri/src/launcher/pins.rs +++ b/src-tauri/src/launcher/pins.rs @@ -8,6 +8,7 @@ pub type Pins = HashMap>; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct ResultPin { pub key: String, pub categories: Vec, diff --git a/src-tauri/src/launcher/portability.rs b/src-tauri/src/launcher/portability.rs index ded3f62..447b8a2 100644 --- a/src-tauri/src/launcher/portability.rs +++ b/src-tauri/src/launcher/portability.rs @@ -19,6 +19,7 @@ const MAX_APPEARANCE_BYTES: usize = 32; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct SettingsImport { pub settings: Settings, pub ignored_keys: Vec, diff --git a/src-tauri/src/launcher/preferences.rs b/src-tauri/src/launcher/preferences.rs index 56558ec..31415e0 100644 --- a/src-tauri/src/launcher/preferences.rs +++ b/src-tauri/src/launcher/preferences.rs @@ -12,6 +12,7 @@ use crate::{ #[derive(Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct SettingsInfo { settings: Settings, defaults: Settings, diff --git a/src-tauri/src/launcher/query.rs b/src-tauri/src/launcher/query.rs index 03af506..925fcb1 100644 --- a/src-tauri/src/launcher/query.rs +++ b/src-tauri/src/launcher/query.rs @@ -4,6 +4,7 @@ use crate::error::{Error, Result}; #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum SearchMode { #[default] All, diff --git a/src-tauri/src/launcher/result.rs b/src-tauri/src/launcher/result.rs index 57db2a2..9ecd1f4 100644 --- a/src-tauri/src/launcher/result.rs +++ b/src-tauri/src/launcher/result.rs @@ -6,6 +6,7 @@ mod contract_tests; #[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum ResultKind { App, File, @@ -51,6 +52,7 @@ impl ResultKind { #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum Action { Launch, Open, @@ -63,6 +65,7 @@ pub enum Action { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct ActionConfirmation { pub title: String, pub description: String, @@ -71,22 +74,27 @@ pub struct ActionConfirmation { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct SearchResult { pub id: String, pub kind: ResultKind, pub title: String, pub subtitle: String, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] pub path: Option, pub score: u32, pub icon: Option, pub primary_action: Action, pub secondary_actions: Vec, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] pub pin: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] pub confirmation: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] pub detail: Option, } @@ -96,6 +104,7 @@ pub struct SearchResult { rename_all = "camelCase", rename_all_fields = "camelCase" )] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum ToolDetail { Password { variant: String, @@ -107,6 +116,7 @@ pub enum ToolDetail { local: String, source_zone: String, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] target_zone: Option, ambiguous: bool, }, @@ -128,6 +138,7 @@ pub enum ToolDetail { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct SearchResponse { pub preferred_selection_id: Option, pub results: Vec, diff --git a/src-tauri/src/launcher/updates.rs b/src-tauri/src/launcher/updates.rs index 0bf629e..aa97a2b 100644 --- a/src-tauri/src/launcher/updates.rs +++ b/src-tauri/src/launcher/updates.rs @@ -23,6 +23,7 @@ impl Drop for BusyGuard<'_> { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct UpdateStatus { pub available: bool, pub version: Option, diff --git a/src-tauri/src/launcher/warning.rs b/src-tauri/src/launcher/warning.rs index 2f7526d..3845d17 100644 --- a/src-tauri/src/launcher/warning.rs +++ b/src-tauri/src/launcher/warning.rs @@ -4,6 +4,7 @@ use serde::Serialize; /// that are only displayed remain strings; do not classify their English text. #[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum WarningCode { SettingsRead, ShortcutRegistration, @@ -16,6 +17,7 @@ pub enum WarningCode { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct LauncherWarning { pub code: WarningCode, pub message: String, diff --git a/src-tauri/src/launcher/window.rs b/src-tauri/src/launcher/window.rs index c61a7dd..6c2d04d 100644 --- a/src-tauri/src/launcher/window.rs +++ b/src-tauri/src/launcher/window.rs @@ -7,6 +7,7 @@ use crate::error::{Error, Result}; #[derive(Clone, Copy, serde::Deserialize)] #[serde(rename_all = "lowercase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub enum LauncherAppearance { Light, Dark, diff --git a/src-tauri/src/launcher/wire_types_tests.rs b/src-tauri/src/launcher/wire_types_tests.rs new file mode 100644 index 0000000..42bc8df --- /dev/null +++ b/src-tauri/src/launcher/wire_types_tests.rs @@ -0,0 +1,367 @@ +//! Test-only schema generation. Serde remains the runtime wire implementation. +use super::{result::*, *}; +use crate::settings; +use std::{collections::BTreeMap, path::Path}; +use syn::{GenericArgument, PathArguments, Type, visit::Visit}; +use ts_rs::TS; + +fn declaration(types: &mut BTreeMap, config: &ts_rs::Config) { + assert!(types.insert(T::ident(config), T::decl(config)).is_none()); +} + +// Parse real signatures, not sample values. Unknown syntax/types fail closed. +fn wire_type(ty: &Type, types: &BTreeMap) -> String { + if let Type::Tuple(tuple) = ty { + assert!( + tuple.elems.is_empty(), + "Non-unit tuple needs contract support" + ); + return "void".into(); + } + let Type::Path(path) = ty else { + panic!("Unsupported command type") + }; + let segment = path.path.segments.last().unwrap(); + let name = segment.ident.to_string(); + let arguments = match &segment.arguments { + PathArguments::None => vec![], + PathArguments::AngleBracketed(args) => args + .args + .iter() + .map(|arg| { + let GenericArgument::Type(ty) = arg else { + panic!("Unsupported generic argument") + }; + ty + }) + .collect(), + _ => panic!("Unsupported command type arguments"), + }; + match name.as_str() { + "Result" => { + assert_eq!(arguments.len(), 2); + assert_eq!( + wire_type(arguments[1], types), + "string", + "Review changed command error contract" + ); + wire_type(arguments[0], types) + } + "Option" | "Vec" => { + assert_eq!(arguments.len(), 1); + let inner = wire_type(arguments[0], types); + if name == "Option" { + format!("{inner} | null") + } else { + format!("Array<{inner}>") + } + } + _ => { + assert!(arguments.is_empty()); + match name.as_str() { + "String" => "string".into(), + "bool" => "boolean".into(), + "u32" | "u64" | "i64" | "usize" => "number".into(), + _ => { + assert!( + types.contains_key(&name), + "Add ts-rs declaration for {name}" + ); + name + } + } + } + } +} + +#[derive(Default)] +struct Registrations(Vec); +impl<'ast> Visit<'ast> for Registrations { + fn visit_macro(&mut self, node: &'ast syn::Macro) { + if node.path.segments.last().unwrap().ident == "generate_handler" { + use syn::parse::Parser; + let paths = syn::punctuated::Punctuated::::parse_terminated + .parse2(node.tokens.clone()) + .unwrap(); + self.0.extend( + paths + .iter() + .map(|p| p.segments.last().unwrap().ident.to_string()), + ); + } + syn::visit::visit_macro(self, node); + } +} + +// ts-rs treats conditional skips conservatively for bidirectional types. These +// declarations describe serialized responses, so Option::is_none must have a +// test-only ts(optional). Check both directions and reject unsupported overrides. +fn check_attributes(attrs: &[syn::Attribute], field: bool) { + let mut skipped_none = false; + let mut optional = false; + for attr in attrs { + if attr.path().is_ident("serde") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip_serializing_if") && field { + let predicate: syn::LitStr = meta.value()?.parse()?; + assert_eq!( + predicate.value(), + "Option::is_none", + "Review serialization predicate" + ); + skipped_none = true; + } else if ["rename", "rename_all", "rename_all_fields", "tag"] + .iter() + .any(|key| meta.path.is_ident(key)) + { + let _: syn::LitStr = meta.value()?.parse()?; + } else if !field + && (meta.path.is_ident("default") || meta.path.is_ident("deny_unknown_fields")) + { + // Deserialization-only rules do not make serialized keys optional. + } else { + panic!("Unsupported serde rule in wire declaration"); + } + Ok(()) + }) + .unwrap(); + } + if attr.path().is_ident("ts") { + panic!("Wire overrides must be test-only and checked"); + } + if attr.path().is_ident("cfg_attr") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("test") { + return Ok(()); + } + if meta.path.is_ident("ts") { + meta.parse_nested_meta(|meta| { + assert!( + field && meta.path.is_ident("optional"), + "Unsupported ts override" + ); + optional = true; + Ok(()) + })?; + } else if meta.path.is_ident("derive") { + let content; + syn::parenthesized!(content in meta.input); + let _: syn::Path = content.parse()?; + } else { + panic!("Unsupported conditional wire attribute"); + } + Ok(()) + }) + .unwrap(); + } + } + assert_eq!( + skipped_none, optional, + "ts(optional) must exactly match serde's Option::is_none skip" + ); +} + +fn check_shape(item: &syn::Item, types: &BTreeMap) { + let (attrs, fields): (_, Vec<_>) = match item { + syn::Item::Struct(item) if types.contains_key(&item.ident.to_string()) => { + (&item.attrs, item.fields.iter().collect()) + } + syn::Item::Enum(item) if types.contains_key(&item.ident.to_string()) => { + for variant in &item.variants { + check_attributes(&variant.attrs, false); + } + ( + &item.attrs, + item.variants.iter().flat_map(|v| &v.fields).collect(), + ) + } + _ => return, + }; + check_attributes(attrs, false); + for field in fields { + check_attributes(&field.attrs, true); + } +} + +fn commands(dir: &Path, types: &BTreeMap, output: &mut BTreeMap) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + commands(&path, types, output); + continue; + } + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let source = syn::parse_file(&std::fs::read_to_string(path).unwrap()).unwrap(); + for item in source.items { + check_shape(&item, types); + let syn::Item::Fn(function) = item else { + continue; + }; + let Some(attr) = function.attrs.iter().find(|attr| { + let parts: Vec<_> = attr + .path() + .segments + .iter() + .map(|p| p.ident.to_string()) + .collect(); + parts == ["tauri", "command"] + }) else { + continue; + }; + assert!( + matches!(attr.meta, syn::Meta::Path(_)), + "Review command options" + ); + assert!(function.sig.generics.params.is_empty()); + let mut args = vec![]; + for arg in &function.sig.inputs { + let syn::FnArg::Typed(arg) = arg else { + panic!("Unexpected receiver") + }; + if let Type::Path(path) = &*arg.ty + && (path.path.is_ident("AppHandle") || path.path.is_ident("WebviewWindow")) + { + continue; + } + let syn::Pat::Ident(id) = &*arg.pat else { + panic!("Unsupported parameter pattern") + }; + args.push(format!("{}: {}", id.ident, wire_type(&arg.ty, types))); + } + let result = match &function.sig.output { + syn::ReturnType::Default => "void".into(), + syn::ReturnType::Type(_, ty) => wire_type(ty, types), + }; + let name = function.sig.ident.to_string(); + assert!( + output + .insert( + name, + format!("{{ args: [{}]; result: {result} }}", args.join(", ")) + ) + .is_none() + ); + } + } +} + +#[test] +fn command_signature_parser_tracks_success_and_argument_domains() { + let dir = tempfile::tempdir().unwrap(); + let source = "#[tauri::command] pub fn hide_launcher(app: AppHandle) -> Result<(), String> {}"; + let mut baseline = BTreeMap::new(); + std::fs::write(dir.path().join("commands.rs"), source).unwrap(); + commands(dir.path(), &BTreeMap::new(), &mut baseline); + assert_eq!(baseline["hide_launcher"], "{ args: []; result: void }"); + for changed in [ + source.replace("Result<(), String>", "Result"), + source.replace("app: AppHandle", "app: AppHandle, value: bool"), + source.replace("app: AppHandle", "app: AppHandle, value: Option"), + ] { + std::fs::write(dir.path().join("commands.rs"), changed).unwrap(); + let mut actual = BTreeMap::new(); + commands(dir.path(), &BTreeMap::new(), &mut actual); + assert_ne!( + actual, baseline, + "Signature drift must change the checked declaration" + ); + } + std::fs::write( + dir.path().join("commands.rs"), + source.replace("Result<(), String>", "UnknownResponse"), + ) + .unwrap(); + assert!( + std::panic::catch_unwind(|| commands(dir.path(), &BTreeMap::new(), &mut BTreeMap::new())) + .is_err() + ); +} + +#[test] +fn optional_wire_annotations_fail_closed() { + let types = BTreeMap::from([("Example".into(), String::new())]); + let source = r#"struct Example { + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + value: Option, + }"#; + check_shape(&syn::parse_str(source).unwrap(), &types); + for changed in [ + source.replace("#[cfg_attr(test, ts(optional))]", ""), + source.replace("#[serde(skip_serializing_if = \"Option::is_none\")]", ""), + source.replace("Option::is_none", "custom_predicate"), + source.replace("ts(optional)", "ts(type = \"string\")"), + source.replace( + "value: Option", + "#[serde(serialize_with = \"custom\")] value: Option", + ), + ] { + let item = syn::parse_str(&changed).unwrap(); + assert!( + std::panic::catch_unwind(|| check_shape(&item, &types)).is_err(), + "Unchecked serde/ts drift must fail: {changed}" + ); + } +} + +#[test] +fn generated_ipc_wire_types_match_frontend() { + // JSON numbers (including SQLite i64 IDs) arrive as JS numbers, not bigint. + let config = ts_rs::Config::new().with_large_int("number"); + let mut types = BTreeMap::new(); + macro_rules! register { ($($ty:ty),+ $(,)?) => { $(declaration::<$ty>(&mut types, &config);)+ }; } + register!( + Action, + ActionConfirmation, + ResultKind, + SearchResult, + SearchResponse, + ToolDetail, + query::SearchMode, + pins::ResultPin, + warning::LauncherWarning, + warning::WarningCode, + files::FileStatus, + currency::CurrencyStatus, + window::LauncherAppearance, + Settings, + settings::AppPreference, + settings::CategoryShortcut, + settings::WebSearch, + LauncherInfo, + preferences::SettingsInfo, + portability::SettingsImport, + updates::UpdateStatus, + crate::providers::clipboard::ClipboardEntry, + ); + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut signatures = BTreeMap::new(); + commands(&root.join("src"), &types, &mut signatures); + let mut registered = Registrations::default(); + registered.visit_file( + &syn::parse_file(&std::fs::read_to_string(root.join("src/lib.rs")).unwrap()).unwrap(), + ); + registered.0.sort(); + assert_eq!(registered.0, signatures.keys().cloned().collect::>()); + let mut source = + "// Generated by generated_ipc_wire_types_match_frontend. Do not edit.\n".to_string(); + for declaration in types.values() { + source.push_str(&format!("export {declaration}\n")); + } + source.push_str("export type Commands = {\n"); + for (name, shape) in signatures { + source.push_str(&format!(" {name}: {shape};\n")); + } + source.push_str("};\n"); + let path = root.join("../tests/fixtures/ipc-wire.ts"); + if std::env::var_os("TINYDASH_UPDATE_CONTRACTS").is_some() { + std::fs::write(&path, &source).unwrap(); + } + assert_eq!( + std::fs::read_to_string(path).unwrap(), + source, + "Wire declarations drifted; regenerate with TINYDASH_UPDATE_CONTRACTS=1 and review" + ); +} diff --git a/src-tauri/src/providers/clipboard.rs b/src-tauri/src/providers/clipboard.rs index e6eb9db..8df5769 100644 --- a/src-tauri/src/providers/clipboard.rs +++ b/src-tauri/src/providers/clipboard.rs @@ -104,6 +104,7 @@ pub fn combine_entries(entries: &[&ClipboardEntry], separator: &str) -> Result, pub hidden: bool, @@ -17,6 +18,7 @@ pub struct AppPreference { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct CategoryShortcut { pub mode: SearchMode, pub shortcut: String, @@ -24,6 +26,7 @@ pub struct CategoryShortcut { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct WebSearch { pub name: String, pub keyword: String, @@ -88,6 +91,7 @@ impl WebSearch { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(default, rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] pub struct Settings { pub clear_query_on_open: bool, pub hide_on_blur: bool, diff --git a/src/bridge.ts b/src/bridge.ts index 1e9a5ba..f791d7b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -79,7 +79,7 @@ export interface SearchResult { title: string; description: string; confirmLabel: string; - } | null; + }; } export interface LauncherWarning { @@ -96,7 +96,7 @@ export interface LauncherWarning { } export interface SearchResponse { - preferredSelectionId?: string | null; + preferredSelectionId: string | null; results: SearchResult[]; total: number; indexing: boolean; @@ -136,9 +136,9 @@ export interface WebSearch { export interface SettingsImport { settings: SettingsValues; ignoredKeys: string[]; - appearance?: string | null; - compact?: boolean | null; - followSystemGlass?: boolean | null; + appearance: string | null; + compact: boolean | null; + followSystemGlass: boolean | null; } export interface UpdateStatus { @@ -150,10 +150,10 @@ export interface UpdateStatus { export interface LauncherInfo { settings: SettingsValues; - platform: "macos" | "windows" | "linux"; + platform: string; warnings: LauncherWarning[]; - visible?: boolean; - initialMode?: SearchMode | null; + visible: boolean; + initialMode: SearchMode | null; } export interface SettingsInfo { diff --git a/src/settingsDraft.ts b/src/settingsDraft.ts index 514c6a0..686e2af 100644 --- a/src/settingsDraft.ts +++ b/src/settingsDraft.ts @@ -47,11 +47,16 @@ export function mergeSettingsDraft( next.appPreferences, ); for (const [id, preference] of Object.entries(current.appPreferences)) { - if (next.appPreferences[id]) { + // Rust omits default preferences. A remote deletion is therefore an + // incoming { aliases: [], hidden: false }, not an absent update. + if ( + next.appPreferences[id] || + !equal(preference, previous.appPreferences[id]) + ) { merged.appPreferences[id] = mergeDraft( previous.appPreferences[id] ?? { aliases: [], hidden: false }, preference, - next.appPreferences[id], + next.appPreferences[id] ?? { aliases: [], hidden: false }, ); } } diff --git a/tests/fixtures/ipc-contract.ts b/tests/fixtures/ipc-contract.ts index 85fc79e..c9b374b 100644 --- a/tests/fixtures/ipc-contract.ts +++ b/tests/fixtures/ipc-contract.ts @@ -177,6 +177,45 @@ export const contracts = { "webSearches": [] } }, + "importedDefaults": { + "appearance": null, + "compact": null, + "followSystemGlass": true, + "ignoredKeys": [], + "settings": { + "appPreferences": {}, + "categoryShortcuts": [], + "clearQueryOnOpen": true, + "clipboardHistoryDecided": true, + "clipboardHistoryEnabled": true, + "clipboardHistoryLimit": 100, + "currencyRatesEnabled": true, + "fileSearchExcludedDirs": [ + "node_modules", + "target" + ], + "fileSearchLimit": 50000, + "fileSearchRoots": null, + "fileWatchEnabled": true, + "hideOnBlur": true, + "shortcut": "Control+Shift+Space", + "startAtLogin": false, + "visibleCategories": [ + "all", + "apps", + "files", + "clipboard", + "calculator", + "system", + "emoji", + "password", + "timezone", + "url", + "web" + ], + "webSearches": [] + } + }, "launcher": { "initialMode": "apps", "platform": "linux", @@ -265,6 +304,12 @@ export const contracts = { "url", "web" ], + "noUpdate": { + "available": false, + "message": "Up to date", + "notes": null, + "version": null + }, "response": { "currency": { "asOf": null, @@ -525,9 +570,15 @@ export const contracts = { "update": { "available": true, "message": "Update available", - "notes": null, + "notes": "Release notes", "version": "0.2.0" }, + "usedClipboard": { + "content": "Used", + "createdAt": 1, + "id": 42, + "lastUsedAt": 2 + }, "warningResponse": { "currency": { "asOf": "2026-09-28", diff --git a/tests/fixtures/ipc-wire.ts b/tests/fixtures/ipc-wire.ts new file mode 100644 index 0000000..46bd636 --- /dev/null +++ b/tests/fixtures/ipc-wire.ts @@ -0,0 +1,60 @@ +// Generated by generated_ipc_wire_types_match_frontend. Do not edit. +export type Action = "launch" | "open" | "reveal" | "copy" | "delete" | "run" | "regenerate"; +export type ActionConfirmation = { title: string, description: string, confirmLabel: string, }; +export type AppPreference = { aliases: Array, hidden: boolean, }; +export type CategoryShortcut = { mode: SearchMode, shortcut: string, }; +export type ClipboardEntry = { id: number, content: string, createdAt: number, lastUsedAt: number | null, }; +export type CurrencyStatus = { asOf: string | null, refreshing: boolean, warning: string | null, }; +export type FileStatus = { total: number, indexing: boolean, warning: string | null, }; +export type LauncherAppearance = "light" | "dark" | "sage" | "rose" | "ink"; +export type LauncherInfo = { settings: Settings, platform: string, warnings: Array, visible: boolean, initialMode: SearchMode | null, }; +export type LauncherWarning = { code: WarningCode, message: string, +/** + * Whether repeating the originating operation can recover without restart + * or configuration repair. This is metadata, not an instruction to retry. + */ +retryable: boolean, }; +export type ResultKind = "app" | "file" | "folder" | "calculation" | "emoji" | "clipboard" | "systemCommand" | "password" | "timezone" | "cleanedUrl" | "webSearch"; +export type ResultPin = { key: string, categories: Array, }; +export type SearchMode = "all" | "apps" | "files" | "emoji" | "calculator" | "clipboard" | "system" | "password" | "timezone" | "url" | "web"; +export type SearchResponse = { preferredSelectionId: string | null, results: Array, total: number, indexing: boolean, indexError: string | null, notice: string | null, storageError: LauncherWarning | null, files: FileStatus, currency: CurrencyStatus, }; +export type SearchResult = { id: string, kind: ResultKind, title: string, subtitle: string, path?: string, score: number, icon: string | null, primaryAction: Action, secondaryActions: Array, pin?: ResultPin, confirmation?: ActionConfirmation, detail?: ToolDetail, }; +export type Settings = { clearQueryOnOpen: boolean, hideOnBlur: boolean, shortcut: string, categoryShortcuts: Array, startAtLogin: boolean, appPreferences: { [key in string]: AppPreference }, webSearches: Array, clipboardHistoryEnabled: boolean, clipboardHistoryDecided: boolean, clipboardHistoryLimit: number, fileSearchRoots: Array | null, fileSearchLimit: number, fileSearchExcludedDirs: Array, fileWatchEnabled: boolean, currencyRatesEnabled: boolean, visibleCategories: Array, }; +export type SettingsImport = { settings: Settings, ignoredKeys: Array, appearance: string | null, compact: boolean | null, followSystemGlass: boolean | null, }; +export type SettingsInfo = { settings: Settings, defaults: Settings, platform: string, version: string, configPath: string, dataPath: string, shortcutsAvailable: boolean, }; +export type ToolDetail = { "type": "password", variant: string, entropyBits: number, strength: string, } | { "type": "timezone", source: string, local: string, sourceZone: string, targetZone?: string, ambiguous: boolean, } | { "type": "dateCalculation", expression: string, basedOn: string, result: string, } | { "type": "cleanedUrl", original: string, removed: number, } | { "type": "webSearch", engine: string, query: string, url: string, }; +export type UpdateStatus = { available: boolean, version: string | null, notes: string | null, message: string, }; +export type WarningCode = "settingsRead" | "shortcutRegistration" | "shortcutsUnavailable" | "clipboardLimited" | "trayUnavailable" | "storageUnavailable" | "clipboardUnavailable"; +export type WebSearch = { name: string, keyword: string, template: string, enabled: boolean, }; +export type Commands = { + app_catalog: { args: []; result: Array }; + check_update: { args: []; result: UpdateStatus }; + choose_clipboard_history: { args: [enabled: boolean]; result: Settings }; + clear_clipboard_history: { args: [keep_pinned: boolean | null]; result: void }; + clipboard_preview: { args: [id: string]; result: ClipboardEntry }; + copy_clipboard_selection: { args: [ids: Array, separator: string]; result: void }; + edit_clipboard_history: { args: [id: string, text: string]; result: void }; + execute_action: { args: [id: string, action: Action, confirmed: boolean | null]; result: void }; + export_settings: { args: [appearance: string, compact: boolean, follow_system_glass: boolean]; result: boolean }; + get_settings: { args: []; result: SettingsInfo }; + hide_launcher: { args: []; result: void }; + install_update: { args: []; result: void }; + launcher_ready: { args: []; result: LauncherInfo }; + open_settings: { args: []; result: void }; + preview_settings_import: { args: []; result: SettingsImport | null }; + preview_web_search: { args: [search: WebSearch, query: string]; result: string }; + quit_app: { args: []; result: void }; + refresh_apps: { args: []; result: void }; + refresh_currency: { args: []; result: void }; + refresh_files: { args: []; result: void }; + reset_launcher_position: { args: []; result: void }; + reveal_backup: { args: []; result: void }; + reveal_settings_path: { args: [data: boolean]; result: void }; + save_clipboard_file: { args: [id: string]; result: boolean }; + save_settings: { args: [settings: Settings]; result: Settings }; + search: { args: [query: string, mode: SearchMode]; result: SearchResponse }; + set_app_preference: { args: [id: string, aliases: Array, hidden: boolean]; result: Settings }; + set_launcher_appearance: { args: [appearance: LauncherAppearance]; result: boolean }; + set_pinned: { args: [id: string, category: SearchMode, pinned: boolean]; result: void }; + set_shortcut_recording: { args: [recording: boolean]; result: void }; +}; diff --git a/tests/ipc-contract.spec.ts b/tests/ipc-contract.spec.ts index af79893..047763b 100644 --- a/tests/ipc-contract.spec.ts +++ b/tests/ipc-contract.spec.ts @@ -8,6 +8,7 @@ import type { SearchResult, } from "../src/bridge"; import { contracts } from "./fixtures/ipc-contract"; +import { commandWrappers } from "./ipc-types"; // A new TS variant also needs a serialized Rust example, not just a widened union. type MissingExamples = @@ -40,7 +41,7 @@ function variants(path: string, name: string) { .sort(); } -test("canonical serde fixtures cover every variant and optional wire shape", () => { +test("canonical serde fixtures cover variants and representative omitted/nullable values", () => { expect(complete).toBe(true); expect([...contracts.actions].sort()).toEqual( variants("launcher/result.rs", "Action"), @@ -65,6 +66,12 @@ test("canonical serde fixtures cover every variant and optional wire shape", () "Restart", ); expect(contracts.fullResult.pin.categories).toEqual(contracts.modes); + expect(contracts.clipboard.lastUsedAt).toBeNull(); + expect(contracts.usedClipboard.lastUsedAt).toBe(2); + expect(contracts.update.notes).toBe("Release notes"); + expect(contracts.noUpdate.notes).toBeNull(); + expect(contracts.imported.followSystemGlass).toBeNull(); + expect(contracts.importedDefaults.followSystemGlass).toBe(true); expect(contracts.defaults.fileSearchRoots).toBeNull(); expect(contracts.settings.fileSearchRoots).toEqual(["/example"]); expect(contracts.warningResponse.storageError).toEqual({ @@ -126,7 +133,10 @@ test("every bridge wrapper invokes a registered Rust command with matching argum return probeCommands(settings); }, contracts.settings); expect([...new Set(calls.map((call) => call.command))].sort()).toEqual(names); - for (const { command, args } of calls) { + for (const { command, args, wrapper } of calls) { + expect(wrapper).toBe( + commandWrappers[command as keyof typeof commandWrappers], + ); const signature = signatures.get(command)!; const required = signature.filter((arg) => !arg.type.startsWith("Option<")); expect( diff --git a/tests/ipc-contract.ts b/tests/ipc-contract.ts index 993e3b3..b0fc782 100644 --- a/tests/ipc-contract.ts +++ b/tests/ipc-contract.ts @@ -22,8 +22,11 @@ export interface ContractFixture { settings: SettingsValues; defaults: SettingsValues; clipboard: ClipboardEntry; + usedClipboard: ClipboardEntry; imported: SettingsImport; + importedDefaults: SettingsImport; update: UpdateStatus; + noUpdate: UpdateStatus; launcher: LauncherInfo; settingsInfo: SettingsInfo; } diff --git a/tests/ipc-drift.spec.ts b/tests/ipc-drift.spec.ts new file mode 100644 index 0000000..1170a3e --- /dev/null +++ b/tests/ipc-drift.spec.ts @@ -0,0 +1,176 @@ +import { expect, test } from "@playwright/test"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const bridge = readFileSync(resolve(root, "src/bridge.ts"), "utf8"); +const wire = readFileSync(resolve(root, "tests/fixtures/ipc-wire.ts"), "utf8"); +const checks = readFileSync(resolve(root, "tests/ipc-types.ts"), "utf8"); + +// Compile isolated copies, never mutate the checkout or reuse stale diagnostics. +// A clean baseline and a contract-specific TS2344 are required for every probe. +const mutations = [ + [ + "optional result addition", + "bridge", + "export interface SearchResult {", + "export interface SearchResult { extra?: string;", + ], + [ + "nested optional addition", + "bridge", + " key: string;", + " key: string; extra?: boolean;", + ], + [ + "nested required becomes optional", + "bridge", + " categories: SearchMode[];", + " categories?: SearchMode[];", + ], + [ + "nullable narrowing", + "bridge", + "lastUsedAt: number | null", + "lastUsedAt: null", + ], + [ + "nullable widening", + "bridge", + "lastUsedAt: number | null", + "lastUsedAt: number | string | null", + ], + ["notes narrowing", "bridge", "notes: string | null", "notes: null"], + [ + "system glass narrowing", + "bridge", + "followSystemGlass: boolean | null", + "followSystemGlass: null", + ], + [ + "required nullable becomes optional", + "bridge", + "preferredSelectionId: string | null", + "preferredSelectionId?: string | null", + ], + [ + "result enum widening", + "bridge", + '| "systemCommand"', + '| "systemCommand" | "invalid"', + ], + ["nested enum narrowing", "bridge", 'type: "cleanedUrl"', 'type: "password"'], + [ + "success return widening", + "bridge", + 'invoke("hide_launcher")', + 'invoke("hide_launcher")', + ], + [ + "argument enum widening", + "bridge", + 'appearance: "light" | "dark" | "sage" | "rose" | "ink"', + 'appearance: "light" | "dark" | "sage" | "rose" | "ink" | "invalid"', + ], + [ + "argument enum narrowing", + "bridge", + 'appearance: "light" | "dark" | "sage" | "rose" | "ink"', + 'appearance: "dark"', + ], + [ + "argument optionality", + "bridge", + "recordShortcut: (recording: boolean)", + "recordShortcut: (recording?: boolean)", + ], + [ + "Rust-derived return drift", + "wire", + "hide_launcher: { args: []; result: void }", + "hide_launcher: { args: []; result: number }", + ], + [ + "Rust-derived field drift", + "wire", + "lastUsedAt: number | null", + "lastUsedAt: string | null", + ], + [ + "Rust-derived enum drift", + "wire", + 'export type LauncherAppearance = "light"', + 'export type LauncherAppearance = "invalid" | "light"', + ], +] as const; + +test("exact Rust-derived contracts reject deliberate wire and bridge drift", async ({}, testInfo) => { + test.setTimeout(120_000); + mkdirSync(resolve(root, "test-results"), { recursive: true }); + const dir = mkdtempSync(resolve(root, "test-results/ipc-drift-")); + mkdirSync(resolve(dir, "src")); + mkdirSync(resolve(dir, "tests/fixtures"), { recursive: true }); + writeFileSync(resolve(dir, "tests/ipc-types.ts"), checks); + writeFileSync( + resolve(dir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + types: [], + }, + include: ["src", "tests"], + }), + ); + const compile = (source: string, canonical: string) => { + writeFileSync(resolve(dir, "src/bridge.ts"), source); + writeFileSync(resolve(dir, "tests/fixtures/ipc-wire.ts"), canonical); + const result = spawnSync( + "bun", + [ + resolve(root, "node_modules/typescript/bin/tsc"), + "--pretty", + "false", + "--project", + resolve(dir, "tsconfig.json"), + ], + { encoding: "utf8", timeout: 20_000 }, + ); + expect(result.error).toBeUndefined(); + return { status: result.status, output: result.stdout + result.stderr }; + }; + try { + expect(compile(bridge, wire)).toEqual({ status: 0, output: "" }); + for (const [name, target, from, to] of mutations) { + await test.step(name, async () => { + const original = target === "bridge" ? bridge : wire; + expect(original.split(from).length, `${name}: unique mutation`).toBe(2); + const changed = original.replace(from, to); + const result = compile( + target === "bridge" ? changed : bridge, + target === "wire" ? changed : wire, + ); + await testInfo.attach(name, { + body: result.output, + contentType: "text/plain", + }); + expect(result.status, `${name}: ${result.output}`).not.toBe(0); + expect(result.output, name).toMatch( + /ipc-types\.ts\(\d+,\d+\): error TS2344/, + ); + }); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/ipc-probe.ts b/tests/ipc-probe.ts index d8f1d56..152f743 100644 --- a/tests/ipc-probe.ts +++ b/tests/ipc-probe.ts @@ -3,9 +3,18 @@ import { backend, type SettingsValues } from "../src/bridge"; /** Exercise the real bridge wrappers without starting the native application. */ export async function probeCommands(settings: SettingsValues) { - const calls: { command: string; args: Record }[] = []; + let wrapper: keyof typeof backend; + const calls: { + wrapper: keyof typeof backend; + command: string; + args: Record; + }[] = []; mockIPC((command, args) => { - calls.push({ command, args: (args ?? {}) as Record }); + calls.push({ + wrapper, + command, + args: (args ?? {}) as Record, + }); }); const probes = { setLauncherAppearance: () => backend.setLauncherAppearance("dark"), @@ -43,7 +52,11 @@ export async function probeCommands(settings: SettingsValues) { quit: backend.quit, } satisfies Record Promise>; try { - for (const probe of Object.values(probes)) await probe(); + for (const [name, probe] of Object.entries(probes)) { + wrapper = name as keyof typeof backend; + await probe(); + } + wrapper = "execute"; await backend.execute("app:example", "launch"); return calls; } finally { diff --git a/tests/ipc-types.ts b/tests/ipc-types.ts new file mode 100644 index 0000000..d904c5f --- /dev/null +++ b/tests/ipc-types.ts @@ -0,0 +1,94 @@ +import type * as Bridge from "../src/bridge"; +import type * as Wire from "./fixtures/ipc-wire"; + +// Expand aliases recursively before equality: assignability alone accepts extra +// optional keys, narrower null/enum domains, and nested changes. +type Expand = T extends object ? { [K in keyof T]: Expand } : T; +type Equal = + (() => T extends Expand ? 1 : 2) extends () => T extends Expand + ? 1 + : 2 + ? true + : false; +type Assert = T; + +export type WireTypesMatch = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, +]; + +// This only binds wrapper names to commands. Domains and success types come +// from parsed Rust signatures, and the runtime probe verifies these bindings. +export const commandWrappers = { + set_launcher_appearance: "setLauncherAppearance", + launcher_ready: "ready", + open_settings: "openSettings", + get_settings: "settings", + save_settings: "saveSettings", + choose_clipboard_history: "chooseClipboardHistory", + app_catalog: "appCatalog", + set_app_preference: "setAppPreference", + preview_web_search: "previewWebSearch", + export_settings: "exportSettings", + preview_settings_import: "importSettings", + reveal_backup: "revealBackup", + check_update: "checkUpdate", + install_update: "installUpdate", + set_shortcut_recording: "recordShortcut", + reveal_settings_path: "revealSettings", + search: "search", + set_pinned: "setPinned", + execute_action: "execute", + clipboard_preview: "clipboardPreview", + clear_clipboard_history: "clearClipboard", + edit_clipboard_history: "editClipboardCopy", + copy_clipboard_selection: "copyClipboardSelection", + save_clipboard_file: "saveClipboardFile", + hide_launcher: "hide", + reset_launcher_position: "resetPosition", + refresh_apps: "refresh", + refresh_files: "refreshFiles", + refresh_currency: "refreshCurrency", + quit_app: "quit", +} as const satisfies Record; + +type Wrappers = typeof commandWrappers; +type Actual = { + [C in keyof Wire.Commands]: { + args: Parameters<(typeof Bridge.backend)[Wrappers[C]]>; + result: Awaited>; + }; +}; + +// The bridge represents Rust Option arguments by omission (not explicit null). +// revealSettings additionally supplies a false default for Rust's required bool. +// No other argument-domain or success-type adaptations are permitted. +type WrapperArgs = T extends [infer First, ...infer Rest] + ? null extends First + ? [arg?: NonNullable, ...WrapperArgs] + : [arg: First, ...WrapperArgs] + : []; +type Expected = { + [C in keyof Wire.Commands]: { + args: C extends "reveal_settings_path" + ? Partial + : WrapperArgs; + result: Wire.Commands[C]["result"]; + }; +}; +export type BridgeCommandsMatch = [ + Assert>, + Assert>, +]; diff --git a/tests/launcher-controller.spec.ts b/tests/launcher-controller.spec.ts index a7cbbcd..6809fc0 100644 --- a/tests/launcher-controller.spec.ts +++ b/tests/launcher-controller.spec.ts @@ -110,6 +110,69 @@ test("hidden and disposed controllers reject late replies and do not send hidden }); }); +test("hide then reopen before the old reply settles keeps the new search pending", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + const calls: string[] = []; + const replies: ((response: SearchResponse) => void)[] = []; + const controller = createLauncherController({ + desktop: true, + send: (value) => { + calls.push(value); + return new Promise((resolve) => replies.push(resolve)); + }, + }); + const old = controller.search("old session"); + controller.hidden(); + controller.setVisible(true); + const reopened = controller.search("new session"); + const beforeOldReply = { calls: [...calls], pending: controller.pending() }; + replies[0]({ + ...response, + notice: "stale", + storageError: { + code: "storageUnavailable", + message: "stale", + retryable: false, + }, + }); + await Promise.resolve(); + const afterOldReply = { + calls: [...calls], + pending: controller.pending(), + count: controller.results().length, + notice: controller.notice() ?? null, + warning: controller.storageError() ?? null, + }; + replies[1]({ ...response, notice: "current" }); + await Promise.all([old, reopened]); + const settled = { + pending: controller.pending(), + count: controller.results().length, + notice: controller.notice(), + }; + controller.dispose(); + return { beforeOldReply, afterOldReply, settled }; + }, contracts.response); + expect(result).toEqual({ + beforeOldReply: { calls: ["old session"], pending: true }, + afterOldReply: { + calls: ["old session", "new session"], + pending: true, + count: 0, + notice: null, + warning: null, + }, + settled: { pending: false, count: 11, notice: "current" }, + }); +}); + test("controller clears stale welcome rows, reports failures, and gates non-desktop search", async ({ page, }) => { diff --git a/tests/mock-backend.ts b/tests/mock-backend.ts index f1edf5a..5d56771 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -137,7 +137,7 @@ const systemCommands: SearchResult[] = [ : "Save your work before you continue.", confirmLabel: title, } - : null, + : undefined, })); const commandQueries = new Map([ ["sleep", systemCommands[2]], diff --git a/tests/settings-draft.spec.ts b/tests/settings-draft.spec.ts index 23f158d..9c4b35d 100644 --- a/tests/settings-draft.spec.ts +++ b/tests/settings-draft.spec.ts @@ -44,6 +44,23 @@ test("merges local deletions and additions while accepting remote deletions", () ).toEqual({}); }); +test("remote unhide removes the saved preference but preserves dirty aliases", () => { + const saved = settings(); + saved.appPreferences["app:example"] = { aliases: [], hidden: true }; + const draft = structuredClone(saved); + draft.appPreferences["app:example"].aliases = ["local"]; + const incoming = structuredClone(saved); + delete incoming.appPreferences["app:example"]; + const before = structuredClone({ saved, draft, incoming }); + const result = mergeSettingsDraft(saved, draft, incoming, "custom"); + expect(result.draft.appPreferences["app:example"]).toEqual({ + aliases: ["local"], + hidden: false, + }); + expect(result.saved.appPreferences).toEqual({}); + expect({ saved, draft, incoming }).toEqual(before); +}); + test("preserves incomplete folder mode and locally edited exclusion text", () => { const saved = settings(); saved.fileSearchRoots = null; diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts index 3459afc..c9b4633 100644 --- a/tests/settings.spec.ts +++ b/tests/settings.spec.ts @@ -362,6 +362,44 @@ test("keeps an edited alias when another window hides apps", async ({ }); }); +test("keeps dirty aliases but accepts another window unhiding a default preference", async ({ + page, +}) => { + await openSettings(page); + await page.evaluate(async () => { + const state = window.__launcherTest; + state.settings = { + ...state.settings, + appPreferences: { "app-0": { aliases: [], hidden: true } }, + }; + await state.emit("settings-changed", state.settings); + }); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page.locator('select[size="6"]').selectOption("app-0"); + const hidden = page.getByRole("checkbox", { + name: "Hide this app from search", + }); + await expect(hidden).toBeChecked(); + await page.getByLabel("Aliases, one per line").fill("local alias"); + await page.getByLabel("Aliases, one per line").blur(); + await page.evaluate(async () => { + const state = window.__launcherTest; + state.settings = { ...state.settings, appPreferences: {} }; + await state.emit("settings-changed", state.settings); + }); + await expect(hidden).not.toBeChecked(); + await expect(page.getByLabel("Aliases, one per line")).toHaveValue( + "local alias", + ); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Changes saved.", { exact: true })).toBeVisible(); + expect( + await page.evaluate(() => window.__launcherTest.settings.appPreferences), + ).toEqual({ + "app-0": { aliases: ["local alias"], hidden: false }, + }); +}); + test("records, saves, and reloads the launch shortcut and window preferences", async ({ page, }) => { @@ -741,6 +779,8 @@ test("previews imports without replacing saved settings and preserves failed imp settings: { ...window.__launcherTest.settings, hideOnBlur: false }, ignoredKeys: ["futureField"], appearance: "dark", + compact: null, + followSystemGlass: null, }; }); await page.getByRole("button", { name: "Privacy", exact: true }).click(); From 7a09a3bcdae08283796a83e5d42944febdab6776 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 13:23:59 +0800 Subject: [PATCH 3/5] test(integration): derive complete cancellation and relay contracts --- src-tauri/Cargo.toml | 6 +- src-tauri/src/appearance.rs | 1 + src-tauri/src/launcher/wire_types_tests.rs | 126 ++++++++++++++++++--- src/bridge.ts | 3 +- tests/fixtures/ipc-wire.ts | 6 +- tests/ipc-contract.spec.ts | 66 ++++------- tests/ipc-drift.spec.ts | 4 + tests/ipc-types.ts | 2 + tests/launcher-controller.spec.ts | 13 ++- 9 files changed, 162 insertions(+), 65 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fcae4ec..d3b2cb0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,8 @@ tauri-plugin-updater = { version = "2.11.0", default-features = false, features [dev-dependencies] # Exercise the real IPC authorization path without opening native windows. tauri = { version = "2.11.5", features = ["test"] } +ts-rs = { version = "12", features = ["no-serde-warnings"] } +syn = { version = "2", features = ["full", "visit"] } [target.'cfg(target_os = "macos")'.dependencies] plist = "1.10.1" @@ -71,10 +73,6 @@ gio-unix = "0.22.8" # Tauri requires GTK 0.18. GTK 0.19 conflicts over the native `gtk-3` link. gtk = "0.18.2" -[dev-dependencies] -ts-rs = { version = "12", features = ["no-serde-warnings"] } -syn = { version = "2", features = ["full", "visit"] } - [profile.release] strip = true lto = "thin" diff --git a/src-tauri/src/appearance.rs b/src-tauri/src/appearance.rs index 805128c..ff04a98 100644 --- a/src-tauri/src/appearance.rs +++ b/src-tauri/src/appearance.rs @@ -3,6 +3,7 @@ use tauri::{AppHandle, Emitter, EventTarget, Runtime}; use crate::launcher::window::LauncherAppearance; #[derive(serde::Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] #[serde(tag = "kind", content = "value", rename_all = "camelCase")] pub enum AppearanceChange { Appearance(LauncherAppearance), diff --git a/src-tauri/src/launcher/wire_types_tests.rs b/src-tauri/src/launcher/wire_types_tests.rs index 42bc8df..02dfaeb 100644 --- a/src-tauri/src/launcher/wire_types_tests.rs +++ b/src-tauri/src/launcher/wire_types_tests.rs @@ -110,9 +110,15 @@ fn check_attributes(attrs: &[syn::Attribute], field: bool) { "Review serialization predicate" ); skipped_none = true; - } else if ["rename", "rename_all", "rename_all_fields", "tag"] - .iter() - .any(|key| meta.path.is_ident(key)) + } else if [ + "rename", + "rename_all", + "rename_all_fields", + "tag", + "content", + ] + .iter() + .any(|key| meta.path.is_ident(key)) { let _: syn::LitStr = meta.value()?.parse()?; } else if !field @@ -183,7 +189,29 @@ fn check_shape(item: &syn::Item, types: &BTreeMap) { } } -fn commands(dir: &Path, types: &BTreeMap, output: &mut BTreeMap) { +#[derive(Debug, PartialEq, Eq)] +struct CommandSignature { + args: Vec<(String, String)>, + result: String, +} + +impl CommandSignature { + fn declaration(&self) -> String { + let args = self + .args + .iter() + .map(|(name, ty)| format!("{name}: {ty}")) + .collect::>() + .join(", "); + format!("{{ args: [{args}]; result: {} }}", self.result) + } +} + +fn commands( + dir: &Path, + types: &BTreeMap, + output: &mut BTreeMap, +) { for entry in std::fs::read_dir(dir).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { @@ -214,21 +242,51 @@ fn commands(dir: &Path, types: &BTreeMap, output: &mut BTreeMap< matches!(attr.meta, syn::Meta::Path(_)), "Review command options" ); - assert!(function.sig.generics.params.is_empty()); + let generics = &function.sig.generics; + assert!(generics.where_clause.is_none(), "Review command bounds"); + let runtime_generic = !generics.params.is_empty(); + if runtime_generic { + assert_eq!(generics.params.len(), 1, "Review command generics"); + let syn::GenericParam::Type(parameter) = &generics.params[0] else { + panic!("Only the injected Tauri runtime may be generic") + }; + assert!(parameter.ident == "R" && parameter.default.is_none()); + assert!(parameter.attrs.is_empty()); + assert_eq!(parameter.bounds.len(), 1); + assert!( + matches!(¶meter.bounds[0], syn::TypeParamBound::Trait(bound) + if bound.path.is_ident("Runtime") && bound.lifetimes.is_none() + && matches!(bound.modifier, syn::TraitBoundModifier::None)) + ); + } let mut args = vec![]; for arg in &function.sig.inputs { let syn::FnArg::Typed(arg) = arg else { panic!("Unexpected receiver") }; if let Type::Path(path) = &*arg.ty - && (path.path.is_ident("AppHandle") || path.path.is_ident("WebviewWindow")) + && path.qself.is_none() + && path.path.segments.len() == 1 + && let segment = &path.path.segments[0] + && (segment.ident == "AppHandle" || segment.ident == "WebviewWindow") { + match &segment.arguments { + PathArguments::None => {} + PathArguments::AngleBracketed(arguments) => { + assert!(runtime_generic && arguments.args.len() == 1); + assert!( + matches!(&arguments.args[0], GenericArgument::Type(Type::Path(path)) + if path.path.is_ident("R")) + ); + } + _ => panic!("Review injected handle type"), + } continue; } let syn::Pat::Ident(id) = &*arg.pat else { panic!("Unsupported parameter pattern") }; - args.push(format!("{}: {}", id.ident, wire_type(&arg.ty, types))); + args.push((id.ident.to_string(), wire_type(&arg.ty, types))); } let result = match &function.sig.output { syn::ReturnType::Default => "void".into(), @@ -237,10 +295,7 @@ fn commands(dir: &Path, types: &BTreeMap, output: &mut BTreeMap< let name = function.sig.ident.to_string(); assert!( output - .insert( - name, - format!("{{ args: [{}]; result: {result} }}", args.join(", ")) - ) + .insert(name, CommandSignature { args, result }) .is_none() ); } @@ -254,7 +309,10 @@ fn command_signature_parser_tracks_success_and_argument_domains() { let mut baseline = BTreeMap::new(); std::fs::write(dir.path().join("commands.rs"), source).unwrap(); commands(dir.path(), &BTreeMap::new(), &mut baseline); - assert_eq!(baseline["hide_launcher"], "{ args: []; result: void }"); + assert_eq!( + baseline["hide_launcher"].declaration(), + "{ args: []; result: void }" + ); for changed in [ source.replace("Result<(), String>", "Result"), source.replace("app: AppHandle", "app: AppHandle, value: bool"), @@ -279,6 +337,35 @@ fn command_signature_parser_tracks_success_and_argument_domains() { ); } +#[test] +fn ipc_generic_commands_only_ignore_the_injected_runtime() { + let dir = tempfile::tempdir().unwrap(); + let source = "#[tauri::command] pub fn sync_appearance(app: AppHandle, change: bool) -> Result<(), String> {}"; + std::fs::write(dir.path().join("commands.rs"), source).unwrap(); + let mut signatures = BTreeMap::new(); + commands(dir.path(), &BTreeMap::new(), &mut signatures); + assert_eq!( + signatures["sync_appearance"].declaration(), + "{ args: [change: boolean]; result: void }" + ); + for changed in [ + source.replace("R: Runtime", "R: Clone"), + source.replace("AppHandle", "AppHandle"), + source.replace("change: bool", "change: R"), + source.replace("R: Runtime", "R: Runtime, T"), + ] { + std::fs::write(dir.path().join("commands.rs"), changed).unwrap(); + assert!( + std::panic::catch_unwind(|| commands( + dir.path(), + &BTreeMap::new(), + &mut BTreeMap::new() + )) + .is_err() + ); + } +} + #[test] fn optional_wire_annotations_fail_closed() { let types = BTreeMap::from([("Example".into(), String::new())]); @@ -314,6 +401,7 @@ fn generated_ipc_wire_types_match_frontend() { macro_rules! register { ($($ty:ty),+ $(,)?) => { $(declaration::<$ty>(&mut types, &config);)+ }; } register!( Action, + crate::appearance::AppearanceChange, ActionConfirmation, ResultKind, SearchResult, @@ -351,10 +439,20 @@ fn generated_ipc_wire_types_match_frontend() { source.push_str(&format!("export {declaration}\n")); } source.push_str("export type Commands = {\n"); - for (name, shape) in signatures { - source.push_str(&format!(" {name}: {shape};\n")); + for (name, shape) in &signatures { + source.push_str(&format!(" {name}: {};\n", shape.declaration())); } source.push_str("};\n"); + // Runtime wrapper probes consume the same parsed argument metadata, not a + // second regex parser which could mistake Rust test strings for handlers. + let arguments: BTreeMap<_, _> = signatures + .iter() + .map(|(name, signature)| (name, &signature.args)) + .collect(); + source.push_str(&format!( + "export const commandArguments = {} as const;\n", + serde_json::to_string(&arguments).unwrap() + )); let path = root.join("../tests/fixtures/ipc-wire.ts"); if std::env::var_os("TINYDASH_UPDATE_CONTRACTS").is_some() { std::fs::write(&path, &source).unwrap(); diff --git a/src/bridge.ts b/src/bridge.ts index 9bf283d..c84c98f 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -3,7 +3,8 @@ import type { Appearance } from "./appearance"; export type AppearanceChange = | { kind: "appearance"; value: Appearance } - | { kind: "compact" | "systemGlass"; value: boolean }; + | { kind: "compact"; value: boolean } + | { kind: "systemGlass"; value: boolean }; export type Action = "launch" | "open" | "reveal" | "copy" | "delete" | "run" | "regenerate"; diff --git a/tests/fixtures/ipc-wire.ts b/tests/fixtures/ipc-wire.ts index 46bd636..2c6d2c4 100644 --- a/tests/fixtures/ipc-wire.ts +++ b/tests/fixtures/ipc-wire.ts @@ -2,6 +2,7 @@ export type Action = "launch" | "open" | "reveal" | "copy" | "delete" | "run" | "regenerate"; export type ActionConfirmation = { title: string, description: string, confirmLabel: string, }; export type AppPreference = { aliases: Array, hidden: boolean, }; +export type AppearanceChange = { "kind": "appearance", "value": LauncherAppearance } | { "kind": "compact", "value": boolean } | { "kind": "systemGlass", "value": boolean }; export type CategoryShortcut = { mode: SearchMode, shortcut: string, }; export type ClipboardEntry = { id: number, content: string, createdAt: number, lastUsedAt: number | null, }; export type CurrencyStatus = { asOf: string | null, refreshing: boolean, warning: string | null, }; @@ -28,6 +29,7 @@ export type WarningCode = "settingsRead" | "shortcutRegistration" | "shortcutsUn export type WebSearch = { name: string, keyword: string, template: string, enabled: boolean, }; export type Commands = { app_catalog: { args: []; result: Array }; + cancel_search: { args: [request_id: number]; result: void }; check_update: { args: []; result: UpdateStatus }; choose_clipboard_history: { args: [enabled: boolean]; result: Settings }; clear_clipboard_history: { args: [keep_pinned: boolean | null]; result: void }; @@ -52,9 +54,11 @@ export type Commands = { reveal_settings_path: { args: [data: boolean]; result: void }; save_clipboard_file: { args: [id: string]; result: boolean }; save_settings: { args: [settings: Settings]; result: Settings }; - search: { args: [query: string, mode: SearchMode]; result: SearchResponse }; + search: { args: [query: string, mode: SearchMode, request_id: number | null]; result: SearchResponse }; set_app_preference: { args: [id: string, aliases: Array, hidden: boolean]; result: Settings }; set_launcher_appearance: { args: [appearance: LauncherAppearance]; result: boolean }; set_pinned: { args: [id: string, category: SearchMode, pinned: boolean]; result: void }; set_shortcut_recording: { args: [recording: boolean]; result: void }; + sync_appearance: { args: [change: AppearanceChange]; result: void }; }; +export const commandArguments = {"app_catalog":[],"cancel_search":[["request_id","number"]],"check_update":[],"choose_clipboard_history":[["enabled","boolean"]],"clear_clipboard_history":[["keep_pinned","boolean | null"]],"clipboard_preview":[["id","string"]],"copy_clipboard_selection":[["ids","Array"],["separator","string"]],"edit_clipboard_history":[["id","string"],["text","string"]],"execute_action":[["id","string"],["action","Action"],["confirmed","boolean | null"]],"export_settings":[["appearance","string"],["compact","boolean"],["follow_system_glass","boolean"]],"get_settings":[],"hide_launcher":[],"install_update":[],"launcher_ready":[],"open_settings":[],"preview_settings_import":[],"preview_web_search":[["search","WebSearch"],["query","string"]],"quit_app":[],"refresh_apps":[],"refresh_currency":[],"refresh_files":[],"reset_launcher_position":[],"reveal_backup":[],"reveal_settings_path":[["data","boolean"]],"save_clipboard_file":[["id","string"]],"save_settings":[["settings","Settings"]],"search":[["query","string"],["mode","SearchMode"],["request_id","number | null"]],"set_app_preference":[["id","string"],["aliases","Array"],["hidden","boolean"]],"set_launcher_appearance":[["appearance","LauncherAppearance"]],"set_pinned":[["id","string"],["category","SearchMode"],["pinned","boolean"]],"set_shortcut_recording":[["recording","boolean"]],"sync_appearance":[["change","AppearanceChange"]]} as const; diff --git a/tests/ipc-contract.spec.ts b/tests/ipc-contract.spec.ts index 047763b..cd5cd52 100644 --- a/tests/ipc-contract.spec.ts +++ b/tests/ipc-contract.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { commandArguments } from "./fixtures/ipc-wire"; import { resolve } from "node:path"; import type { Action, @@ -82,42 +83,15 @@ test("canonical serde fixtures cover variants and representative omitted/nullabl }); function commands() { - const registrations = rust("lib.rs") - .split("tauri::generate_handler![")[1] - ?.split("]")[0]; - expect(registrations).toBeDefined(); - const names = registrations - .split(",") - .map((name) => name.trim().split("::").at(-1)!) - .filter(Boolean) - .sort(); - const signatures = new Map(); - const paths = readdirSync(resolve(root, "src-tauri/src/launcher"), { - recursive: true, - }).filter((path) => typeof path === "string" && path.endsWith(".rs")); - for (const path of paths) { - const source = rust(`launcher/${path}`); - // These internal commands use simple owned parameters. Fail closed when a - // new signature is not understood instead of silently skipping the command. - for (const match of source.matchAll( - /#\[tauri::command\]\s*pub (?:async )?fn (\w+)\(([^)]*)\)/g, - )) { - const args = match[2] - .split(",") - .map((arg) => arg.trim()) - .filter(Boolean) - .map((arg) => { - const parameter = /^(\w+):\s*(.+)$/.exec(arg); - if (!parameter) - throw new Error(`Unrecognized command parameter: ${arg}`); - return { name: camelCase(parameter[1]), type: parameter[2] }; - }) - .filter((arg) => !["AppHandle", "WebviewWindow"].includes(arg.type)); - signatures.set(match[1], args); - } - } - expect([...signatures.keys()].sort()).toEqual(names); - return { names, signatures }; + // Rust's syn-based generator checks this metadata against registered handlers. + // Do not parse Rust again here: test strings are not command declarations. + const signatures = new Map( + Object.entries(commandArguments).map(([command, args]) => [ + command, + args.map(([name, type]) => ({ name: camelCase(name), type })), + ]), + ); + return { names: [...signatures.keys()].sort(), signatures }; } test("every bridge wrapper invokes a registered Rust command with matching argument names and types", async ({ @@ -138,7 +112,7 @@ test("every bridge wrapper invokes a registered Rust command with matching argum commandWrappers[command as keyof typeof commandWrappers], ); const signature = signatures.get(command)!; - const required = signature.filter((arg) => !arg.type.startsWith("Option<")); + const required = signature.filter((arg) => !arg.type.endsWith(" | null")); expect( Object.keys(args).filter( (name) => !signature.some((arg) => arg.name === name), @@ -149,15 +123,15 @@ test("every bridge wrapper invokes a registered Rust command with matching argum for (const [name, value] of Object.entries(args)) { const type = signature .find((arg) => arg.name === name)! - .type.replace(/^Option<(.+)>$/, "$1"); + .type.replace(/ \| null$/, ""); switch (type) { - case "String": + case "string": expect(typeof value, `${command}.${name}`).toBe("string"); break; - case "bool": + case "boolean": expect(typeof value, `${command}.${name}`).toBe("boolean"); break; - case "Vec": + case "Array": expect( Array.isArray(value) && value.every((item) => typeof item === "string"), @@ -172,12 +146,18 @@ test("every bridge wrapper invokes a registered Rust command with matching argum case "Settings": expect(value).toEqual(contracts.settings); break; - case "settings::WebSearch": + case "WebSearch": expect(value).toEqual(contracts.settings.webSearches[0]); break; case "LauncherAppearance": expect(value).toBe("dark"); break; + case "AppearanceChange": + expect(value).toEqual({ kind: "appearance", value: "dark" }); + break; + case "number": + expect(Number.isSafeInteger(value) && Number(value) > 0).toBe(true); + break; default: throw new Error(`Add coverage for ${command}.${name}: ${type}`); } diff --git a/tests/ipc-drift.spec.ts b/tests/ipc-drift.spec.ts index 1170a3e..da06ba3 100644 --- a/tests/ipc-drift.spec.ts +++ b/tests/ipc-drift.spec.ts @@ -117,6 +117,10 @@ test("exact Rust-derived contracts reject deliberate wire and bridge drift", asy const dir = mkdtempSync(resolve(root, "test-results/ipc-drift-")); mkdirSync(resolve(dir, "src")); mkdirSync(resolve(dir, "tests/fixtures"), { recursive: true }); + writeFileSync( + resolve(dir, "src/appearance.ts"), + readFileSync(resolve(root, "src/appearance.ts"), "utf8"), + ); writeFileSync(resolve(dir, "tests/ipc-types.ts"), checks); writeFileSync( resolve(dir, "tsconfig.json"), diff --git a/tests/ipc-types.ts b/tests/ipc-types.ts index d904c5f..f8a11a9 100644 --- a/tests/ipc-types.ts +++ b/tests/ipc-types.ts @@ -33,6 +33,8 @@ export type WireTypesMatch = [ // from parsed Rust signatures, and the runtime probe verifies these bindings. export const commandWrappers = { set_launcher_appearance: "setLauncherAppearance", + sync_appearance: "syncAppearance", + cancel_search: "cancelSearch", launcher_ready: "ready", open_settings: "openSettings", get_settings: "settings", diff --git a/tests/launcher-controller.spec.ts b/tests/launcher-controller.spec.ts index 6809fc0..01cc1c1 100644 --- a/tests/launcher-controller.spec.ts +++ b/tests/launcher-controller.spec.ts @@ -121,11 +121,18 @@ test("hide then reopen before the old reply settles keeps the new search pending )) as typeof import("../src/launcherController"); const calls: string[] = []; const replies: ((response: SearchResponse) => void)[] = []; + let started!: () => void; + const newStarted = new Promise((resolve) => { + started = resolve; + }); const controller = createLauncherController({ desktop: true, send: (value) => { calls.push(value); - return new Promise((resolve) => replies.push(resolve)); + return new Promise((resolve) => { + replies.push(resolve); + if (value === "new session") started(); + }); }, }); const old = controller.search("old session"); @@ -142,7 +149,9 @@ test("hide then reopen before the old reply settles keeps the new search pending retryable: false, }, }); - await Promise.resolve(); + // Cancellation acknowledgement adds microtasks. Observe actual submission, + // not an assumed number of Promise ticks, before resolving the new reply. + await newStarted; const afterOldReply = { calls: [...calls], pending: controller.pending(), From c84a54cad34817e66184ec5ac98083290bd758fa Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 13:53:02 +0800 Subject: [PATCH 4/5] fix(frontend): preserve cancellation and storage recovery metadata --- src-tauri/src/launcher/mod.rs | 16 ++++------------ src-tauri/src/launcher/storage.rs | 28 ++++++++++++++-------------- src/launcherController.ts | 12 +++++++++--- tests/ipc-probe.ts | 5 ++++- tests/launcher-controller.spec.ts | 17 ++++++++++++++++- tests/mock-backend.ts | 6 +++--- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/launcher/mod.rs b/src-tauri/src/launcher/mod.rs index f8e7389..46c7db2 100644 --- a/src-tauri/src/launcher/mod.rs +++ b/src-tauri/src/launcher/mod.rs @@ -254,19 +254,11 @@ pub async fn search( }, results: outcome.results, notice: outcome.notice, - storage_error: state - .storage - .warning() - // Storage health is owned by Storage. Until it supplies typed - // retryability, do not guess from its human-readable message. - .map(|message| { - LauncherWarning::new(WarningCode::StorageUnavailable, message, false) + storage_error: state.storage.warning().or_else(|| { + state.clipboard.warning().map(|message| { + LauncherWarning::new(WarningCode::ClipboardUnavailable, message, true) }) - .or_else(|| { - state.clipboard.warning().map(|message| { - LauncherWarning::new(WarningCode::ClipboardUnavailable, message, true) - }) - }), + }), total: search.app_count(), files: state.files.status(search.file_count()), currency: state.currency.status(search.rates()), diff --git a/src-tauri/src/launcher/storage.rs b/src-tauri/src/launcher/storage.rs index 4543b59..15b6abd 100644 --- a/src-tauri/src/launcher/storage.rs +++ b/src-tauri/src/launcher/storage.rs @@ -6,6 +6,8 @@ use std::{ use tauri::{AppHandle, Manager}; +use super::warning::{LauncherWarning, WarningCode}; + use super::LauncherState; use super::query::SearchMode; use super::search::SearchManager; @@ -32,7 +34,7 @@ enum Health { struct Session { database: Option, health: Health, - warning: Arc>>, + warning: Arc>>, // Before loading these are session-only increments; afterwards they are // absolute counts. A successful initialization merges them exactly once. pending_usage: HashMap, @@ -69,7 +71,11 @@ impl Session { } }; if let Ok(mut warning) = self.warning.lock() { - *warning = Some(message.into()); + *warning = Some(LauncherWarning::new( + WarningCode::StorageUnavailable, + message, + health == Health::Busy, + )); } } @@ -242,7 +248,7 @@ impl Observation { #[derive(Default)] pub struct Storage { database: OnceLock>, - warning: Arc>>, + warning: Arc>>, } impl Storage { @@ -587,7 +593,7 @@ impl Storage { Ok(()) } - pub fn warning(&self) -> Option { + pub fn warning(&self) -> Option { self.warning.lock().ok().and_then(|warning| warning.clone()) } } @@ -716,15 +722,7 @@ mod tests { ); assert_eq!(session.health, Health::Busy); assert!(session.database.is_some()); - assert!( - session - .warning - .lock() - .unwrap() - .as_ref() - .unwrap() - .contains("busy") - ); + assert!(session.warning.lock().unwrap().as_ref().unwrap().retryable); session }); // A failed write keeps the entry visible. @@ -993,7 +991,9 @@ mod tests { .unwrap() .unwrap(); assert!(session.database.is_none()); - assert!(session.warning.lock().unwrap().is_some()); + let warning = session.warning.lock().unwrap().clone().unwrap(); + assert_eq!(warning.code, WarningCode::StorageUnavailable); + assert!(!warning.retryable); } } diff --git a/src/launcherController.ts b/src/launcherController.ts index 3aea4ed..e454643 100644 --- a/src/launcherController.ts +++ b/src/launcherController.ts @@ -35,7 +35,12 @@ export function receiveLauncherSettings( /** Owns the launcher search session, not DOM focus or native command execution. */ export function createLauncherController(options: { desktop: boolean; - send: (value: string, mode: SearchMode) => Promise; + send: ( + value: string, + mode: SearchMode, + requestId?: number, + ) => Promise; + cancelBackend?: (requestId: number) => Promise; }) { const [visible, setVisible] = createSignal(true); const [query, setQuery] = createSignal(""); @@ -70,7 +75,8 @@ export function createLauncherController(options: { let selectionChangedByUser = false; const searches = createSearchQueue({ - send: ({ value, mode }) => options.send(value, mode), + send: ({ value, mode, requestId }) => options.send(value, mode, requestId), + cancelBackend: options.cancelBackend ?? (async () => {}), apply(request, response) { const index = chooseSelection({ request, @@ -152,7 +158,7 @@ export function createLauncherController(options: { }, hidden() { setVisible(false); - // Cancellation invalidates replies; it does not abort Rust work. + // Invalidate delivery immediately and ask Rust to stop cooperatively. searches.cancel(); setPending(false); }, diff --git a/tests/ipc-probe.ts b/tests/ipc-probe.ts index 152f743..45e877a 100644 --- a/tests/ipc-probe.ts +++ b/tests/ipc-probe.ts @@ -18,6 +18,8 @@ export async function probeCommands(settings: SettingsValues) { }); const probes = { setLauncherAppearance: () => backend.setLauncherAppearance("dark"), + syncAppearance: () => + backend.syncAppearance({ kind: "appearance", value: "dark" }), ready: backend.ready, openSettings: backend.openSettings, settings: backend.settings, @@ -35,7 +37,8 @@ export async function probeCommands(settings: SettingsValues) { installUpdate: backend.installUpdate, recordShortcut: () => backend.recordShortcut(true), revealSettings: () => backend.revealSettings(true), - search: () => backend.search("fixture", "all"), + search: () => backend.search("fixture", "all", 1), + cancelSearch: () => backend.cancelSearch(1), setPinned: () => backend.setPinned("app:example", "apps", true), execute: () => backend.execute("app:example", "launch", true), clipboardPreview: () => backend.clipboardPreview("clipboard:1"), diff --git a/tests/launcher-controller.spec.ts b/tests/launcher-controller.spec.ts index 01cc1c1..0e75d5c 100644 --- a/tests/launcher-controller.spec.ts +++ b/tests/launcher-controller.spec.ts @@ -66,14 +66,20 @@ test("hidden and disposed controllers reject late replies and do not send hidden )) as typeof import("../src/launcherController"); let complete!: (response: SearchResponse) => void; let calls = 0; + const requests: (number | undefined)[] = []; + const cancellations: number[] = []; const controller = createLauncherController({ desktop: true, - send: () => { + send: (_value, _mode, requestId) => { calls++; + requests.push(requestId); return new Promise((resolve) => { complete = resolve; }); }, + cancelBackend: async (requestId) => { + cancellations.push(requestId); + }, }); const hidden = controller.search("hidden"); controller.hidden(); @@ -100,6 +106,13 @@ test("hidden and disposed controllers reject late replies and do not send hidden reopenedCount, finalCount: controller.results().length, calls, + cancellationMatches: + cancellations.length === 2 && + cancellations[0] === requests[0] && + cancellations[1] === requests[2], + validRequestIds: requests.every( + (id) => Number.isSafeInteger(id) && (id ?? 0) > 0, + ), }; }, contracts.response); expect(result).toEqual({ @@ -107,6 +120,8 @@ test("hidden and disposed controllers reject late replies and do not send hidden reopenedCount: 11, finalCount: 11, calls: 3, + cancellationMatches: true, + validRequestIds: true, }); }); diff --git a/tests/mock-backend.ts b/tests/mock-backend.ts index df319af..ad86996 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -442,9 +442,6 @@ mockIPC( async (command, payload) => { const state = window.__launcherTest; state.calls.push({ command, payload }); - // Queue tests control delayed search replies independently. Native - // cancellation is covered by Rust, not simulated as desktop proof here. - if (command === "cancel_search") return; if (command.startsWith("plugin:event|")) return mockEvent(command, payload as Record); if (command === "sync_appearance") { @@ -453,6 +450,9 @@ mockIPC( appearanceChannel.postMessage(change); return; } + // Queue tests control delayed search replies independently. Native + // cancellation is covered by Rust, not simulated as desktop proof here. + if (command === "cancel_search") return; if (command === "hide_launcher") { await emit("launcher-hidden"); return; From 4c8555292514b7e337021309b00707960d31a51f Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 14:58:30 +0800 Subject: [PATCH 5/5] ci: rerun frontend PR checks on a fresh runner