diff --git a/CLAUDE.md b/CLAUDE.md index 00555bf..4405d3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,15 +336,23 @@ no editing logic in the adapter. ### embedded MCP server (`crates/kerf-app/src/mcp.rs`) The app **is** the MCP server — there is no separate binary. `mcp::serve` hosts the -tools over `rmcp` 1.7's **streamable-HTTP** transport (`StreamableHttpService` + +tools over `rmcp` 3.1's **streamable-HTTP** transport (`StreamableHttpService` + `LocalSessionManager`, nested into an `axum` router) on `127.0.0.1:7777/mcp` -(`KERF_MCP_ADDR` overrides). It is spawned from `lib.rs`'s Tauri `.setup` hook on +(`KERF_MCP_ADDR` overrides). rmcp validates the inbound **`Host`** header against +an allow-list that defaults to loopback (a DNS-rebinding guard), which would make +every `KERF_MCP_ADDR` override reject its own clients — so `allowed_hosts` (pure + +unit-tested) derives the list from the bind address: a concrete address is added to +the loopback defaults, and a wildcard bind (`0.0.0.0` / `[::]`) can't be enumerated +at all, so it yields an empty list, rmcp's "allow any". +It is spawned from `lib.rs`'s Tauri `.setup` hook on `tauri::async_runtime` and shares the **same** `Arc>` the Tauri commands hold, so the agent edits the project the user has open. Patterns that matter if you edit it: `#[tool_router]` on the impl + `#[tool_handler]` on `impl ServerHandler` — **no `tool_router` field on the struct** (the macro calls `Self::tool_router()`). `ServerInfo` is `#[non_exhaustive]`, so `get_info` builds it via `Default::default()` -then mutates fields. Most tools return `Result` (pretty JSON), but the +then mutates fields — including `server_info` (`server_identity`), because that +default is filled from **rmcp's own** crate identity and left alone the server +introduces itself to every client as "rmcp". Most tools return `Result` (pretty JSON), but the three **visual** tools — `get_frame` (a single drill-in frame), `skim_asset` (a contact-sheet montage of an asset + a text index of cell→timestamp, for finding good parts) and `preview_timeline` (the composited cut at a timeline time) — return @@ -414,6 +422,14 @@ snake_case (`{ assetId }` → `asset_id`). Config: `tauri.conf.json` points which for this `crates/kerf-app` layout resolves to `crates/`, not the config dir or repo root — so they anchor to the repo via `cd "$(git rev-parse --show-toplevel)/frontend" && bun run dev` instead of a fragile relative path. +`build.rs` takes the **Windows app manifest** away from Tauri +(`new_without_app_manifest`) and embeds `windows-app-manifest.xml` through the +linker instead: Tauri's copy rides in the `.res`, which cargo links into *bins* +only, so the lib's test binary ran with no activation context, bound comctl32 +**v5**, and died with `STATUS_ENTRYPOINT_NOT_FOUND` on the `TaskDialogIndirect` +import rfd (via `tauri-plugin-dialog`) contributes — before a single test ran. +Whether the linker pulls that object in at all shifts with unrelated dependency +bumps, which is how an rmcp upgrade broke `cargo test -p kerf-app` on Windows. `capabilities/default.json` grants `core:default` + `dialog:default` + `updater:default` + `process:allow-restart` + `opener:allow-open-url`. That last one enables the command **with no scope of its own** (`allow-default-urls` is a diff --git a/Cargo.lock b/Cargo.lock index bc9c6d3..915cfa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,8 +760,18 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -777,17 +787,41 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.118", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dbus" version = "0.9.11" @@ -868,7 +902,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1060,7 +1094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2159,7 +2193,7 @@ dependencies = [ [[package]] name = "kerf-app" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", "axum", @@ -2184,7 +2218,7 @@ dependencies = [ [[package]] name = "kerf-core" -version = "0.19.1" +version = "0.19.2" dependencies = [ "chrono", "dirs", @@ -2431,7 +2465,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2480,7 +2514,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3287,18 +3321,19 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1a15bc53261a9dc37e105df006e4656c598379a8f9581f8950debb130f27a7cf" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.1", "bytes", "chrono", "futures", "http", "http-body", "http-body-util", + "indexmap 2.14.0", "pastey", "pin-project-lite", "rand", @@ -3318,15 +3353,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -3385,7 +3420,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3442,7 +3477,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3770,7 +3805,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.118", @@ -3905,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3970,9 +4005,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -4519,7 +4554,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4956,7 +4991,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4985,7 +5020,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5441,7 +5476,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6682ae9..971de19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/kerf-core", "crates/kerf-app"] [workspace.package] -version = "0.19.1" +version = "0.19.2" edition = "2021" rust-version = "1.95" license = "PolyForm-Noncommercial-1.0.0" @@ -45,7 +45,7 @@ fontdb = "0.24" ffmpeg-next = "9.0" # MCP — official Rust SDK (streamable-HTTP server transport, hosted by kerf-app) -rmcp = { version = "1.7", features = ["server", "macros", "transport-streamable-http-server"] } +rmcp = { version = "3.1", features = ["server", "macros", "transport-streamable-http-server"] } schemars = "1.2" # HTTP server for the embedded MCP endpoint (matches rmcp's tower/axum 0.8) diff --git a/README.md b/README.md index f040e3a..0c0d90e 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ kerf/ | Styling | Tailwind CSS **4** (CSS config) + design tokens | | Media | FFmpeg binaries (always) · `ffmpeg-next` **8.1** (optional libav) | | Persistence | `rusqlite` (bundled SQLite) — one `.kerf` file | -| MCP | `rmcp` **1.7** (streamable-HTTP transport) | +| MCP | `rmcp` **3.1** (streamable-HTTP transport) | The engine's export and timeline-still paths are **pure and unit-tested** — clip positions, gaps, track layering, effects, keyframes, overlays, ducking and loudnorm all diff --git a/crates/kerf-app/build.rs b/crates/kerf-app/build.rs index 261851f..21aa4c8 100644 --- a/crates/kerf-app/build.rs +++ b/crates/kerf-app/build.rs @@ -1,3 +1,21 @@ fn main() { - tauri_build::build(); + // Tauri's default app manifest — the one asking for Common-Controls v6 — + // rides in the Windows resource file, which cargo links into **bins only**. + // The lib's own test binary therefore starts with no activation context, so + // the loader binds comctl32 v5; and rfd (via tauri-plugin-dialog) statically + // imports `TaskDialogIndirect`, which only v6 exports. The test exe then dies + // with STATUS_ENTRYPOINT_NOT_FOUND before a single test runs. Whether the + // linker pulls that object in at all shifts with unrelated dependency + // changes, so pass the same manifest through the linker instead: that covers + // every binary this crate links, tests included. + let attributes = + tauri_build::Attributes::new().windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); + tauri_build::try_build(attributes).expect("tauri-build failed"); + + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + let manifest = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-app-manifest.xml"); + println!("cargo:rerun-if-changed=windows-app-manifest.xml"); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); + } } diff --git a/crates/kerf-app/src/mcp.rs b/crates/kerf-app/src/mcp.rs index 89aa9e1..c7fe201 100644 --- a/crates/kerf-app/src/mcp.rs +++ b/crates/kerf-app/src/mcp.rs @@ -19,8 +19,10 @@ use kerf_core::{ TextKeyframe, Transition, TransitionKind, VideoEffect, }; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; -use rmcp::transport::streamable_http_server::{session::local::LocalSessionManager, StreamableHttpService}; +use rmcp::model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler}; use serde::Serialize; use tauri::{AppHandle, Emitter}; @@ -1652,10 +1654,18 @@ async fn blocking(job: impl FnOnce() -> Result + .map_err(|e| McpError::internal_error(e.to_string(), None))? } +/// How the server introduces itself to clients. `ServerInfo::default()` fills +/// `server_info` in from *rmcp's* own build env, so an untouched default has +/// every client listing this server as "rmcp". +fn server_identity() -> Implementation { + Implementation::new("kerf", env!("CARGO_PKG_VERSION")) +} + #[tool_handler] impl ServerHandler for KerfMcp { fn get_info(&self) -> ServerInfo { let mut info = ServerInfo::default(); + info.server_info = server_identity(); info.capabilities = ServerCapabilities::builder().enable_tools().build(); info.instructions = Some( "Kerf MCP server. The user queues editing tasks in the desktop app; \ @@ -1729,6 +1739,45 @@ pub fn endpoint_url() -> String { format!("http://{}/mcp", bind_addr()) } +/// The `Host` headers this server will accept. +/// +/// rmcp validates the inbound `Host` against an allow-list that defaults to +/// loopback, to blunt DNS-rebinding attacks against locally running servers. +/// `KERF_MCP_ADDR` exists to move the server *off* loopback, so the bare +/// default would make every such override reject its own clients. Extend the +/// list with whatever the override names: a concrete address is added as-is, +/// while a wildcard bind (`0.0.0.0` / `[::]`) cannot be enumerated at all — +/// the client's `Host` is whichever of this machine's addresses it reached us +/// on — so it yields an empty list, which is rmcp's documented "allow any". +fn allowed_hosts(addr: &str) -> Vec { + let defaults = ["localhost", "127.0.0.1", "::1"]; + let mut hosts: Vec = defaults.iter().map(|h| h.to_string()).collect(); + let Some(host) = host_of(addr) else { + return hosts; + }; + if host.parse::().is_ok_and(|ip| ip.is_unspecified()) { + return Vec::new(); + } + if !hosts.iter().any(|h| h.eq_ignore_ascii_case(&host)) { + hosts.push(host); + } + hosts +} + +/// The host part of a `host:port` bind address, tolerating a bracketed IPv6 +/// literal and a missing port. +fn host_of(addr: &str) -> Option { + if let Ok(sock) = addr.parse::() { + return Some(sock.ip().to_string()); + } + let host = match addr.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => addr, + }; + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + (!host.is_empty()).then(|| host.to_string()) +} + /// Serve the MCP tools over streamable HTTP at `/mcp`, sharing `project` with /// the Tauri commands. Runs until the process exits. pub async fn serve(project: Arc>, app: AppHandle) -> anyhow::Result<()> { @@ -1737,7 +1786,7 @@ pub async fn serve(project: Arc>, app: AppHandle) -> anyhow::Resu let service = StreamableHttpService::new( move || Ok(KerfMcp::new(project.clone(), app.clone())), LocalSessionManager::default().into(), - Default::default(), + StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts(&addr)), ); let router = axum::Router::new().nest_service("/mcp", service); @@ -1790,7 +1839,7 @@ fn core_err(e: kerf_core::Error) -> McpError { /// not a `data:` URL). fn image_result(caption: String, jpeg: Vec) -> CallToolResult { let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg); - CallToolResult::success(vec![Content::text(caption), Content::image(b64, "image/jpeg")]) + CallToolResult::success(vec![ContentBlock::text(caption), ContentBlock::image(b64, "image/jpeg")]) } /// Format a seconds offset as `mm:ss.mmm` for frame / contact-sheet captions. @@ -1809,7 +1858,7 @@ fn json(value: &T) -> Result { #[cfg(test)] mod tests { - use super::fmt_ts; + use super::{allowed_hosts, fmt_ts, image_result, server_identity, KerfMcp}; #[test] fn fmt_ts_carries_at_minute_boundaries() { @@ -1822,4 +1871,142 @@ mod tests { assert_eq!(fmt_ts(59.9994), "00:59.999"); assert_eq!(fmt_ts(125.25), "02:05.250"); } + + /// Every tool the agent can call must reach it with a description and an + /// object input schema. The router is built by the `tool_router` macro, so + /// this is what catches an rmcp upgrade silently changing how the tool + /// surface is generated. + #[test] + fn every_tool_has_a_description_and_object_schema() { + let tools = KerfMcp::tool_router().list_all(); + assert!(tools.len() > 50, "expected the full tool surface, got {}", tools.len()); + + for tool in &tools { + let description = tool.description.as_deref().unwrap_or_default(); + assert!(!description.is_empty(), "tool `{}` has no description", tool.name); + assert_eq!( + tool.input_schema.get("type").and_then(|t| t.as_str()), + Some("object"), + "tool `{}` input schema is not an object", + tool.name + ); + } + } + + /// `Option` parameters must stay out of `required` — a great many tools + /// document a field as "omit to …", and a schema generator that started + /// requiring them would break that contract without failing to compile. + #[test] + fn optional_parameters_are_not_required() { + let tools = KerfMcp::tool_router().list_all(); + let add_clip = tools + .iter() + .find(|t| t.name == "add_clip_to_timeline") + .expect("add_clip_to_timeline is registered"); + + let required: Vec<&str> = add_clip + .input_schema + .get("required") + .and_then(|r| r.as_array()) + .map(|r| r.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + // Documented as required by the tool. + assert!(required.contains(&"asset_id"), "asset_id should be required: {required:?}"); + assert!(required.contains(&"source_in"), "source_in should be required: {required:?}"); + // Documented as "omit to auto-select" / "omit to append". + assert!(!required.contains(&"track_id"), "track_id must stay optional: {required:?}"); + assert!( + !required.contains(&"timeline_start"), + "timeline_start must stay optional: {required:?}" + ); + + let properties = add_clip + .input_schema + .get("properties") + .and_then(|p| p.as_object()) + .expect("input schema has properties"); + for field in ["asset_id", "track_id", "source_in", "source_out", "timeline_start"] { + assert!(properties.contains_key(field), "`{field}` missing from schema"); + } + } + /// The default bind is loopback, which rmcp's own defaults already cover. + #[test] + fn loopback_binds_keep_the_default_allow_list() { + for addr in ["127.0.0.1:7777", "localhost:7777", "[::1]:7777"] { + let hosts = allowed_hosts(addr); + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{addr} -> {hosts:?}"); + assert!(hosts.iter().any(|h| h == "localhost"), "{addr} -> {hosts:?}"); + assert!(hosts.iter().any(|h| h == "::1"), "{addr} -> {hosts:?}"); + // No duplicates from re-adding a host the defaults already list. + assert_eq!(hosts.len(), 3, "{addr} -> {hosts:?}"); + } + } + + /// `KERF_MCP_ADDR` pointed at a concrete non-loopback address has to allow + /// that address, or the override rejects every client it just enabled. + #[test] + fn a_concrete_override_is_allowed() { + let hosts = allowed_hosts("192.168.1.5:7777"); + assert!(hosts.iter().any(|h| h == "192.168.1.5"), "{hosts:?}"); + // The loopback defaults survive alongside it. + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{hosts:?}"); + + let named = allowed_hosts("kerf.local:7777"); + assert!(named.iter().any(|h| h == "kerf.local"), "{named:?}"); + } + + /// A wildcard bind can be reached on any of this machine's addresses, so + /// there is no list to write: an empty list is rmcp's "allow any". + #[test] + fn a_wildcard_bind_disables_host_validation() { + for addr in ["0.0.0.0:7777", "[::]:7777"] { + assert!(allowed_hosts(addr).is_empty(), "{addr} should allow any host"); + } + } + + /// A malformed or port-less override must not silently drop the loopback + /// defaults and lock the user out of the default endpoint. + #[test] + fn odd_addresses_keep_loopback_reachable() { + for addr in ["", ":7777", "127.0.0.1", "not a host:port"] { + let hosts = allowed_hosts(addr); + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{addr} must keep loopback: {hosts:?}"); + } + } + /// Clients list a server by its `serverInfo`, and rmcp's default fills that + /// in from its own crate identity — so the app has to name itself. + #[test] + fn the_server_introduces_itself_as_kerf() { + let info = server_identity(); + assert_eq!(info.name, "kerf"); + assert_eq!(info.version, env!("CARGO_PKG_VERSION")); + assert_ne!(info.name, "rmcp", "server_info must not be rmcp's own identity"); + } + + /// The visual tools hand the model a caption plus a raw-base64 image block. + /// This asserts the shape that actually goes over the wire — rmcp wants + /// bare base64 under `data` with a sibling `mimeType`, never a `data:` URL, + /// and getting that wrong degrades silently into an image the model cannot + /// see rather than into a build failure. + #[test] + fn image_results_serialize_as_caption_plus_bare_base64() { + let jpeg = [0xFF, 0xD8, 0xFF, 0xD9]; + let value = + serde_json::to_value(image_result("at 00:02.000".to_string(), jpeg.to_vec())).expect("CallToolResult serializes"); + + let content = value["content"].as_array().expect("content is an array"); + assert_eq!(content.len(), 2, "expected caption + image: {content:?}"); + + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "at 00:02.000"); + + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["mimeType"], "image/jpeg"); + let data = content[1]["data"].as_str().expect("image data is a string"); + assert_eq!(data, "/9j/2Q=="); + assert!(!data.starts_with("data:"), "must be bare base64, not a data: URL"); + + assert_eq!(value["isError"], false); + } } diff --git a/crates/kerf-app/tauri.conf.json b/crates/kerf-app/tauri.conf.json index 21eef89..34f1e70 100644 --- a/crates/kerf-app/tauri.conf.json +++ b/crates/kerf-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Kerf", - "version": "0.19.1", + "version": "0.19.2", "identifier": "ch.orellbuehler.kerf", "build": { "frontendDist": "../../frontend/build", diff --git a/crates/kerf-app/windows-app-manifest.xml b/crates/kerf-app/windows-app-manifest.xml new file mode 100644 index 0000000..2d510ed --- /dev/null +++ b/crates/kerf-app/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/frontend/package.json b/frontend/package.json index 8bdc4af..59a221e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.19.1", + "version": "0.19.2", "type": "module", "scripts": { "dev": "vite dev",