diff --git a/.prettierignore b/.prettierignore index a71b510..f38916c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,7 @@ native-build designs .hallmark 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/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 58acb10..ea6c9fd 100644 --- a/docs/how-to/verify.md +++ b/docs/how-to/verify.md @@ -43,6 +43,27 @@ 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` 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 -- ipc_ +bun run typecheck +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. 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. + ## CI triggers The Checks workflow always starts for pull requests, merge queues, pushes to `main`, version-tag pushes, and manual requests. Feature-branch pushes do not duplicate the pull request's desktop builds. No workflow-level path filter can leave its required status pending on a documentation-only change. diff --git a/docs/reference/features/launcher.md b/docs/reference/features/launcher.md index 18e6598..05e1aca 100644 --- a/docs/reference/features/launcher.md +++ b/docs/reference/features/launcher.md @@ -22,8 +22,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 tests/ipc-drift.spec.ts ``` +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: ```sh diff --git a/docs/reference/features/settings.md b/docs/reference/features/settings.md index 07e38cb..957b301 100644 --- a/docs/reference/features/settings.md +++ b/docs/reference/features/settings.md @@ -22,8 +22,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, 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: ```sh 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 e66e82e..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" 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/contract_tests.rs b/src-tauri/src/launcher/contract_tests.rs new file mode 100644 index 0000000..a008873 --- /dev/null +++ b/src-tauri/src/launcher/contract_tests.rs @@ -0,0 +1,217 @@ +//! 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 }, + "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 }, + "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", + 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/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 b0c7021..117cc25 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -31,6 +31,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 517d34e..46c7db2 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; #[cfg(test)] @@ -35,6 +36,10 @@ use crate::{ use query::SearchMode; use result::SearchResponse; use search::{SearchBudget, SearchManager}; +use warning::{LauncherWarning, WarningCode}; + +#[cfg(test)] +mod wire_types_tests; pub struct LauncherState { pub search: Mutex, @@ -46,7 +51,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, @@ -55,7 +60,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 { @@ -143,14 +148,37 @@ impl LauncherState { #[derive(Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] 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(); @@ -226,10 +254,11 @@ pub async fn search( }, results: outcome.results, notice: outcome.notice, - storage_error: state - .storage - .warning() - .or_else(|| state.clipboard.warning()), + storage_error: state.storage.warning().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/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 bf80327..958370c 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, @@ -22,6 +23,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 async fn get_settings(app: AppHandle) -> Result { blocking_read(move || { 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 3feef8d..9ecd1f4 100644 --- a/src-tauri/src/launcher/result.rs +++ b/src-tauri/src/launcher/result.rs @@ -1,7 +1,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(test)] +#[path = "contract_tests.rs"] +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, @@ -47,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, @@ -59,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, @@ -67,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, } @@ -92,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, @@ -103,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, }, @@ -124,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, @@ -131,7 +146,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/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-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 new file mode 100644 index 0000000..3845d17 --- /dev/null +++ b/src-tauri/src/launcher/warning.rs @@ -0,0 +1,37 @@ +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")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum WarningCode { + SettingsRead, + ShortcutRegistration, + ShortcutsUnavailable, + ClipboardLimited, + TrayUnavailable, + StorageUnavailable, + ClipboardUnavailable, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +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/launcher/window.rs b/src-tauri/src/launcher/window.rs index f66505c..0187a5c 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::Serialize)] #[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..02dfaeb --- /dev/null +++ b/src-tauri/src/launcher/wire_types_tests.rs @@ -0,0 +1,465 @@ +//! 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", + "content", + ] + .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); + } +} + +#[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() { + 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" + ); + 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.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((id.ident.to_string(), 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, CommandSignature { args, result }) + .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"].declaration(), + "{ 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 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())]); + 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, + crate::appearance::AppearanceChange, + 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}: {};\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(); + } + 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/lib.rs b/src-tauri/src/lib.rs index 06981f9..74a9e9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,7 +18,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>)?; @@ -124,15 +128,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( @@ -152,12 +156,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)); } } } @@ -165,7 +169,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-tauri/src/providers/clipboard.rs b/src-tauri/src/providers/clipboard.rs index 106272a..c978132 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/App.tsx b/src/App.tsx index 687eaad..230126b 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,42 @@ 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, + cancelBackend: backend.cancelSearch, }); - 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 +132,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 +141,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 +171,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,61 +509,6 @@ export default function App( } } - const searches = createSearchQueue({ - send: ({ value, mode, requestId }) => - backend.search(value, mode, requestId), - cancelBackend: backend.cancelSearch, - 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; @@ -895,7 +845,7 @@ export default function App( event.preventDefault(); if (results().length) { const count = results().length; - selectionChangedByUser = true; + markSelectionChanged(); setSelected((index) => { if ( emojiGrid && @@ -972,12 +922,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); @@ -985,38 +940,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); @@ -1070,22 +998,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)}`); } @@ -1094,8 +1020,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); @@ -1322,7 +1248,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 5eb3c33..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"; @@ -84,17 +85,30 @@ export interface SearchResult { title: string; description: string; confirmLabel: string; - } | null; + }; +} + +export interface LauncherWarning { + code: + | "settingsRead" + | "shortcutRegistration" + | "shortcutsUnavailable" + | "clipboardLimited" + | "trayUnavailable" + | "storageUnavailable" + | "clipboardUnavailable"; + message: string; + retryable: boolean; } export interface SearchResponse { - preferredSelectionId?: string | null; + preferredSelectionId: string | null; results: SearchResult[]; total: number; indexing: boolean; indexError: string | null; notice: string | null; - storageError: string | null; + storageError: LauncherWarning | null; files: FileStatus; currency: CurrencyStatus; } @@ -128,9 +142,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 { @@ -142,10 +156,10 @@ export interface UpdateStatus { export interface LauncherInfo { settings: SettingsValues; - platform: "macos" | "windows" | "linux"; - warnings: string[]; - visible?: boolean; - initialMode?: SearchMode | null; + platform: string; + warnings: LauncherWarning[]; + visible: boolean; + initialMode: SearchMode | null; } export interface SettingsInfo { diff --git a/src/launcherController.ts b/src/launcherController.ts new file mode 100644 index 0000000..e454643 --- /dev/null +++ b/src/launcherController.ts @@ -0,0 +1,170 @@ +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, + requestId?: number, + ) => Promise; + cancelBackend?: (requestId: number) => 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, requestId }) => options.send(value, mode, requestId), + cancelBackend: options.cancelBackend ?? (async () => {}), + 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); + // Invalidate delivery immediately and ask Rust to stop cooperatively. + 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..686e2af --- /dev/null +++ b/src/settingsDraft.ts @@ -0,0 +1,78 @@ +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)) { + // 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] ?? { aliases: [], hidden: false }, + ); + } + } + } + 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..c9b374b --- /dev/null +++ b/tests/fixtures/ipc-contract.ts @@ -0,0 +1,605 @@ +// 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": [] + } + }, + "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", + "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" + ], + "noUpdate": { + "available": false, + "message": "Up to date", + "notes": null, + "version": null + }, + "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": "Release notes", + "version": "0.2.0" + }, + "usedClipboard": { + "content": "Used", + "createdAt": 1, + "id": 42, + "lastUsedAt": 2 + }, + "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/fixtures/ipc-wire.ts b/tests/fixtures/ipc-wire.ts new file mode 100644 index 0000000..2c6d2c4 --- /dev/null +++ b/tests/fixtures/ipc-wire.ts @@ -0,0 +1,64 @@ +// 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 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, }; +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 }; + 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 }; + 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, 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 new file mode 100644 index 0000000..cd5cd52 --- /dev/null +++ b/tests/ipc-contract.spec.ts @@ -0,0 +1,174 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { commandArguments } from "./fixtures/ipc-wire"; +import { resolve } from "node:path"; +import type { + Action, + LauncherWarning, + SearchMode, + 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 = + | 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 variants and representative omitted/nullable values", () => { + 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.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({ + code: "storageUnavailable", + message: "Storage unavailable", + retryable: false, + }); +}); + +function commands() { + // 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 ({ + 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, wrapper } of calls) { + expect(wrapper).toBe( + commandWrappers[command as keyof typeof commandWrappers], + ); + const signature = signatures.get(command)!; + const required = signature.filter((arg) => !arg.type.endsWith(" | null")); + 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(/ \| null$/, ""); + switch (type) { + case "string": + expect(typeof value, `${command}.${name}`).toBe("string"); + break; + case "boolean": + expect(typeof value, `${command}.${name}`).toBe("boolean"); + break; + case "Array": + 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 "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}`); + } + } + } + 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..b0fc782 --- /dev/null +++ b/tests/ipc-contract.ts @@ -0,0 +1,32 @@ +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; + 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..da06ba3 --- /dev/null +++ b/tests/ipc-drift.spec.ts @@ -0,0 +1,180 @@ +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, "src/appearance.ts"), + readFileSync(resolve(root, "src/appearance.ts"), "utf8"), + ); + 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 new file mode 100644 index 0000000..45e877a --- /dev/null +++ b/tests/ipc-probe.ts @@ -0,0 +1,68 @@ +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) { + let wrapper: keyof typeof backend; + const calls: { + wrapper: keyof typeof backend; + command: string; + args: Record; + }[] = []; + mockIPC((command, args) => { + calls.push({ + wrapper, + command, + args: (args ?? {}) as Record, + }); + }); + const probes = { + setLauncherAppearance: () => backend.setLauncherAppearance("dark"), + syncAppearance: () => + backend.syncAppearance({ kind: "appearance", value: "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", 1), + cancelSearch: () => backend.cancelSearch(1), + 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 [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 { + clearMocks(); + } +} diff --git a/tests/ipc-types.ts b/tests/ipc-types.ts new file mode 100644 index 0000000..f8a11a9 --- /dev/null +++ b/tests/ipc-types.ts @@ -0,0 +1,96 @@ +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", + sync_appearance: "syncAppearance", + cancel_search: "cancelSearch", + 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 new file mode 100644 index 0000000..0e75d5c --- /dev/null +++ b/tests/launcher-controller.spec.ts @@ -0,0 +1,243 @@ +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 requests: (number | undefined)[] = []; + const cancellations: number[] = []; + const controller = createLauncherController({ + desktop: true, + 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(); + 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, + 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({ + afterHidden: { count: 0, pending: false, calls: 1 }, + reopenedCount: 11, + finalCount: 11, + calls: 3, + cancellationMatches: true, + validRequestIds: true, + }); +}); + +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)[] = []; + 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); + if (value === "new session") started(); + }); + }, + }); + 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, + }, + }); + // 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(), + 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, +}) => { + 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 6ac0ec4..ad86996 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -5,6 +5,7 @@ import type { AppearanceChange, SearchResult, SearchMode, + LauncherWarning, SettingsImport, SettingsValues, UpdateStatus, @@ -31,6 +32,7 @@ declare global { toolRevision: number; rejectActions: boolean | string; storageError: string | null; + warnings: LauncherWarning[]; usedAppFirst: boolean; clipboardDeleted: string[]; clipboardCleared: boolean; @@ -137,7 +139,7 @@ const systemCommands: SearchResult[] = [ : "Save your work before you continue.", confirmLabel: title, } - : null, + : undefined, })); const commandQueries = new Map([ ["sleep", systemCommands[2]], @@ -319,6 +321,7 @@ window.__launcherTest = { toolRevision: 0, rejectActions: false, storageError: null, + warnings: [], usedAppFirst: false, clipboardDeleted: [], clipboardCleared: false, @@ -439,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") { @@ -450,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; @@ -462,7 +465,7 @@ mockIPC( return { platform: state.platform, settings: state.settings, - warnings: [], + warnings: state.warnings, visible: state.initialVisible, initialMode: state.initialMode, }; @@ -703,7 +706,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..9c4b35d --- /dev/null +++ b/tests/settings-draft.spec.ts @@ -0,0 +1,88 @@ +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("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; + 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); +}); 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();