From 4a3c46077374e8c8b01e5f70ae5c4a4bcff6ea9b Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 03:45:39 +0200 Subject: [PATCH 1/7] Paste images again in release builds, and log failed attachment saves The CSP's `connect-src *` does not match custom schemes, so in a bundled build it blocked Tauri's `ipc:` protocol. Tauri then fell back to postMessage for every command without saying so. Since `save_asset` started taking a raw byte body in 1.6.3, that fallback turned every pasted image into JSON, and the command rejected it with "Asset upload must send a raw body". Dropped images that were resized first took the same path. Dev builds load from Vite with no CSP, which is why only release builds broke. Allowing `ipc: http://ipc.localhost` puts release builds on the IPC protocol dev builds already use. The failure was invisible because the handlers only called console.error, which goes nowhere in a release build. Attachment save failures in the editor and queue now go through reportError, which takes an optional context so the log line says what was being attempted. Confirmed on a debug build with the release CSP: pasting logged the raw-body error before the change. --- CHANGELOG.md | 8 ++++++++ src-tauri/tauri.conf.json | 2 +- src/lib/components/Editor.svelte | 9 +++++---- src/lib/components/Queue.svelte | 3 ++- src/lib/utils/__tests__/error-reporting.test.ts | 7 +++++++ src/lib/utils/error-reporting.ts | 16 ++++++++++++---- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce86602..b5bfe83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ popover, not for the person who wrote the commit. ## [Unreleased] +### Fixed +- **Pasting an image into a stash works again.** Since 1.6.3, a pasted screenshot or copied + image was silently dropped: nothing appeared and no error was shown. Dropping a large image + failed the same way when "Resize images" was on, because the shrunk copy is saved the same + way a pasted image is. Text pastes and files dropped from Finder were not affected +- Failed attachment saves are now written to the app log, so a problem like this one leaves a + trace instead of disappearing + ## [1.10.1] - 2026-10-01 ### Changed diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6e0ed10..235515f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost data: blob:; connect-src *; font-src 'self' data:; media-src 'self' asset: https://asset.localhost", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost data: blob:; connect-src * ipc: http://ipc.localhost; font-src 'self' data:; media-src 'self' asset: https://asset.localhost", "assetProtocol": { "enable": true, "scope": [ diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 87b5a04..61517d3 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -68,6 +68,7 @@ import { locale } from "$lib/i18n"; import { tooltip } from "$lib/actions/tooltip"; import { resizeImage, getAttachmentKind } from "$lib/utils/files"; + import { reportError } from "$lib/utils/error-reporting"; import { readFile } from "@tauri-apps/plugin-fs"; let { @@ -324,7 +325,7 @@ addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } } catch (err) { - console.error("Failed to save dropped asset", err); + reportError("attachment", err, "Failed to save dropped asset"); } } }); @@ -552,7 +553,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - console.error("Failed to save pasted file:", err); + reportError("attachment", err, "Failed to save pasted file"); } } return; @@ -618,7 +619,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - console.error("Failed to save text as attachment:", err); + reportError("attachment", err, "Failed to save text as attachment"); } } @@ -1134,7 +1135,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - console.error("Failed to save asset from path", err); + reportError("attachment", err, "Failed to save picked file"); } } } diff --git a/src/lib/components/Queue.svelte b/src/lib/components/Queue.svelte index 47be5ed..74bf699 100644 --- a/src/lib/components/Queue.svelte +++ b/src/lib/components/Queue.svelte @@ -65,6 +65,7 @@ resolveRef, type ResolvedRef, } from "$lib/utils/stash-refs"; + import { reportError } from "$lib/utils/error-reporting"; let { transferMode, @@ -762,7 +763,7 @@ attachment.stashId = stash.id; newAttachments.push(attachment); } catch (err) { - console.error("Failed to save dropped asset", err); + reportError("attachment", err, "Failed to save dropped asset"); } } if (newAttachments.length > stash.attachments.length) { diff --git a/src/lib/utils/__tests__/error-reporting.test.ts b/src/lib/utils/__tests__/error-reporting.test.ts index 7d8a1b6..8e0165f 100644 --- a/src/lib/utils/__tests__/error-reporting.test.ts +++ b/src/lib/utils/__tests__/error-reporting.test.ts @@ -50,6 +50,13 @@ describe('error reporting', () => { expect(forwarded[0]).toContain('42'); }); + it('names what was being attempted when given a context', () => { + reportError('attachment', { code: '', message: 'Asset upload must send a raw body' }, 'Failed to save pasted file'); + + expect(forwarded[0]).toMatch(/^\[attachment\] Failed to save pasted file: /); + expect(forwarded[0]).toContain('Asset upload must send a raw body'); + }); + it('survives a forwarder that throws', () => { // The reporter runs *from* error handlers. If it could throw, it would replace // the original error with a less useful one. diff --git a/src/lib/utils/error-reporting.ts b/src/lib/utils/error-reporting.ts index 10d5c7d..f22e367 100644 --- a/src/lib/utils/error-reporting.ts +++ b/src/lib/utils/error-reporting.ts @@ -27,7 +27,8 @@ export type ErrorSource = | 'render' | 'uncaught' - | 'unhandled-rejection'; + | 'unhandled-rejection' + | 'attachment'; type Reporter = (message: string) => void | Promise; @@ -56,9 +57,16 @@ function describe(error: unknown): string { } } -/** Log an error locally and forward it to the backend. Never throws. */ -export function reportError(source: ErrorSource, error: unknown): void { - const line = `[${source}] ${describe(error)}`; +/** + * Log an error locally and forward it to the backend. Never throws. + * + * `context` says what was being attempted, for errors that are caught and handled - + * a rejected `invoke` carries the backend's message but not which action sent it. + */ +export function reportError(source: ErrorSource, error: unknown, context?: string): void { + const line = context + ? `[${source}] ${context}: ${describe(error)}` + : `[${source}] ${describe(error)}`; try { console.error(line); } catch { From a73a88267f0470c20fa4f00e8e9b0307bf8e6f0e Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 04:34:13 +0200 Subject: [PATCH 2/7] Accept images dragged from a browser, and land every drop where it fell On macOS, Tauri's drop handler only reads file paths, and a browser offers an image as a promised file plus some mix of its bytes, its URL and a TIFF - never a path. Browser drops therefore arrived empty and were ignored. The promise is already gone when the drop reaches us (macOS reports it as such for Chromium and Safari), so a new `read_dropped_image` command reads the drag pasteboard instead: the original bytes when present (Chromium), else the image downloaded from its URL (Safari), else the TIFF re-encoded as PNG. It writes a scratch file under ~/.stashpad/tmp/drop and hands back the path, so the drop takes the Finder path: same resize, same save. Drop positions were divided by devicePixelRatio, but Tauri passes on the webview's own units: device pixels on Windows, CSS pixels on macOS and Linux. On a Retina Mac every drop was hit-tested at half its coordinates. Every editor also handled every drop that missed a card, so a drop into an editor opened on a queue card was taken by the main editor too. The two then read the same drop at once and collided in the scratch folder. Now exactly one place takes a drop: the editor it landed in, a card outside any editor, or else the main editor. Attachment failures now show as a warning chip in the editor, with a `layout: "list"` tooltip that lists them; drop errors carry UiError codes translated under errors.drop. Each case was checked by hand in a bundled debug build. The drag-routing and coordinate handling have unit tests. --- CHANGELOG.md | 17 +- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/browser_drop.rs | 285 ++++++++++++++++++++ src-tauri/src/lib.rs | 4 +- src/lib/actions/tooltip.ts | 39 ++- src/lib/components/Editor.svelte | 82 ++++-- src/lib/components/Queue.svelte | 19 +- src/lib/i18n/locales/de.json | 9 + src/lib/i18n/locales/en.json | 9 + src/lib/i18n/locales/zh.json | 9 + src/lib/services/desktop-adapter.ts | 4 + src/lib/stores/__tests__/drag-state.test.ts | 95 +++++++ src/lib/stores/drag-state.svelte.ts | 42 ++- src/lib/styles/tooltip.css | 34 +++ src/lib/types.ts | 5 + 16 files changed, 623 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/browser_drop.rs create mode 100644 src/lib/stores/__tests__/drag-state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bfe83..31116e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,22 @@ popover, not for the person who wrote the commit. ## [Unreleased] -### Fixed +### Added +- **You can drag images straight from your browser into a stash.** On macOS, an image dropped + from Chrome, Safari or another browser used to be ignored without a word, because the + browser hands it over as something other than a file on disk. Stashpad now keeps the + original file when the browser provides it, and otherwise downloads the image from its + address. When neither works, it saves the picture as a PNG +- **An attachment that could not be added now says so in the editor.** A small warning sign + appears next to the attachments, and hovering it lists what went wrong, such as a download + that failed or a drop with nothing to attach. Click it to dismiss it; it does not get in the + way of typing + +### Fixed +- **A file you drop lands where you dropped it.** On a Mac with a Retina display, every drop + was placed as if it had landed up and to the left of where it really did. A file dropped into + a stash you were editing could end up in the main editor as well, and a file dropped onto one + stash in the queue could be attached to a different one, or to none - **Pasting an image into a stash works again.** Since 1.6.3, a pasted screenshot or copied image was silently dropped: nothing appeared and no error was shown. Dropping a large image failed the same way when "Resize images" was on, because the shrunk copy is saved the same diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77882e2..fd0c0cb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5950,6 +5950,8 @@ dependencies = [ "keyring", "log", "mime_guess", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rand 0.8.8", "regex", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8c84cf..469a74d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -86,6 +86,8 @@ serde_yaml = "0.9" [target.'cfg(target_os = "macos")'.dependencies] fm-rs = { version = "0.1", optional = true } keyring = { version = "3", features = ["apple-native"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSDictionary", "NSString"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"] } diff --git a/src-tauri/src/browser_drop.rs b/src-tauri/src/browser_drop.rs new file mode 100644 index 0000000..6fe7679 --- /dev/null +++ b/src-tauri/src/browser_drop.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +//! Images dragged in from a web browser. +//! +//! Tauri's native drop handler only reports file paths, and on macOS it reads them from +//! `NSFilenamesPboardType` alone. A browser never puts an image there: it offers the image +//! as a promised file, plus some mix of the original bytes, the image URL and a decoded +//! TIFF. So a browser drop arrived as an empty path list and was silently ignored. +//! +//! The promised file cannot be used. By the time the drop event reaches us the drag +//! session is over, and macOS reports the promise as gone ("Couldn't get a copy of an HFS +//! Promise") - checked against Chromium and Safari. What the drag pasteboard still holds +//! is enough, though, so this tries, in order: +//! +//! 1. the original bytes (Chromium browsers put the image file itself on the pasteboard) +//! 2. the image URL, downloaded (Safari offers no original, only the URL and a bitmap) +//! 3. the TIFF bitmap, re-encoded as PNG - offline, but never the original file +//! +//! The result is written to a scratch file and its path handed back, so the interface can +//! treat it exactly like a file dropped from Finder: same resize, same save, same errors. + +use std::path::PathBuf; + +use crate::uierror::UiError; + +/// Scratch folder for the image of the latest browser drop. Emptied on every drop, so at +/// most one stale file lingers - and it is under the app folder the fs scope allows. +fn scratch_dir() -> PathBuf { + crate::utils::get_app_dir().join("tmp").join("drop") +} + +fn nothing_usable() -> UiError { + UiError::new( + "drop.nothing_usable", + "This drop contained nothing Stashpad can attach. Try copying the image and pasting it instead.", + ) +} + +/// Turn the image in the current drag into a file and return its path. +#[tauri::command] +pub async fn read_dropped_image() -> Result { + #[cfg(target_os = "macos")] + { + macos::read().await + } + #[cfg(not(target_os = "macos"))] + { + // Only the macOS drop handler is known to drop browser content on the floor; on + // the other platforms there is no pasteboard to read it back from. + Err(nothing_usable()) + } +} + +/// A filename for the image, from the last segment of its URL when there is one. +fn file_name(url: Option<&str>, ext: &str) -> String { + let stem = url + .and_then(|u| reqwest::Url::parse(u).ok()) + .and_then(|u| u.path_segments()?.next_back().map(str::to_string)) + .map(|s| urlencoding::decode(&s).map(|d| d.into_owned()).unwrap_or(s)) + .map(|s| match s.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem.to_string(), + _ => s, + }) + .map(|s| s.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "dropped-image".to_string()); + format!("{}.{}", stem, ext) +} + +/// Write the image to the scratch folder, replacing whatever the last drop left there. +fn write_scratch(name: &str, bytes: &[u8]) -> Result { + let dir = scratch_dir(); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create {}: {}", dir.display(), e))?; + let path = dir.join(name); + std::fs::write(&path, bytes) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + Ok(path.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "macos")] +mod macos { + use std::time::Duration; + + use objc2_app_kit::{ + NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardNameDrag, + }; + use objc2_foundation::{NSData, NSDictionary, NSString}; + + use super::{file_name, nothing_usable, write_scratch}; + use crate::uierror::UiError; + + /// Original image formats, as pasteboard type and file extension, best first. + const ORIGINAL_TYPES: [(&str, &str); 4] = [ + ("public.png", "png"), + ("public.jpeg", "jpg"), + ("org.webmproject.webp", "webp"), + ("com.compuserve.gif", "gif"), + ]; + + /// A dropped image is a picture on a web page, not a download manager; anything + /// bigger than this is refused rather than held in memory. + const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; + + /// What the drag pasteboard held, copied out so nothing AppKit crosses an `await`. + struct DragContents { + original: Option<(Vec, &'static str)>, + url: Option, + tiff: Option>, + } + + fn read_pasteboard() -> DragContents { + let pb = NSPasteboard::pasteboardWithName(unsafe { NSPasteboardNameDrag }); + let data = |t: &str| pb.dataForType(&NSString::from_str(t)).map(|d| d.to_vec()); + + let original = ORIGINAL_TYPES + .iter() + .find_map(|(t, ext)| data(t).filter(|b| !b.is_empty()).map(|b| (b, *ext))); + let url = pb + .stringForType(&NSString::from_str("public.url")) + .map(|s| s.to_string()) + .filter(|u| u.starts_with("https://") || u.starts_with("http://")); + let tiff = data("public.tiff").filter(|b| !b.is_empty()); + + DragContents { + original, + url, + tiff, + } + } + + pub async fn read() -> Result { + let contents = tauri::async_runtime::spawn_blocking(read_pasteboard) + .await + .map_err(|e| format!("Reading the drop failed: {}", e))?; + + if let Some((bytes, ext)) = &contents.original { + let name = file_name(contents.url.as_deref(), ext); + return write_scratch(&name, bytes); + } + + let mut download_error = None; + if let Some(url) = &contents.url { + match download(url).await { + Ok((bytes, ext)) => return write_scratch(&file_name(Some(url), &ext), &bytes), + Err(reason) => { + log::warn!("Could not download dropped image {}: {}", url, reason); + download_error = Some(reason); + } + } + } + + if let Some(tiff) = contents.tiff { + let url = contents.url.clone(); + let png = tauri::async_runtime::spawn_blocking(move || tiff_to_png(&tiff)) + .await + .map_err(|e| format!("Converting the drop failed: {}", e))?; + if let Some(png) = png { + return write_scratch(&file_name(url.as_deref(), "png"), &png); + } + } + + Err(match download_error { + Some(reason) => UiError::with_values( + "drop.download_failed", + format!("Couldn't download the dropped image: {}", reason), + [("reason", reason)], + ), + None => nothing_usable(), + }) + } + + /// Fetch the image behind a dropped URL, with its file extension. + async fn download(url: &str) -> Result<(Vec, String), String> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .build() + .map_err(|e| e.to_string())?; + let response = client.get(url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + + let mime = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(|v| v.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if !mime.starts_with("image/") { + let shown = if mime.is_empty() { + "no content type" + } else { + mime.as_str() + }; + return Err(format!("the address returned {}, not an image", shown)); + } + let ext = match mime.as_str() { + // mime_guess lists "jpe" first for image/jpeg + "image/jpeg" => "jpg".to_string(), + _ => mime_guess::get_mime_extensions_str(&mime) + .and_then(|exts| exts.first()) + .map(|e| e.to_string()) + .unwrap_or_else(|| mime.trim_start_matches("image/").to_string()), + }; + + if response + .content_length() + .is_some_and(|len| len > MAX_DOWNLOAD_BYTES) + { + return Err("the image is larger than 50 MB".into()); + } + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_DOWNLOAD_BYTES { + return Err("the image is larger than 50 MB".into()); + } + Ok((bytes.to_vec(), ext)) + } + + fn tiff_to_png(tiff: &[u8]) -> Option> { + let rep = NSBitmapImageRep::imageRepWithData(&NSData::with_bytes(tiff))?; + let png = unsafe { + rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) + }?; + Some(png.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::file_name; + + #[test] + fn names_the_file_after_the_url() { + assert_eq!( + file_name( + Some("https://example.com/uploads/Kaesekuchen-02.jpg"), + "jpg" + ), + "Kaesekuchen-02.jpg" + ); + } + + #[test] + fn swaps_the_extension_for_the_actual_format() { + assert_eq!( + file_name( + Some("https://images.example.com/16x9-big/kaese406.webp?width=1920"), + "png" + ), + "kaese406.png" + ); + } + + #[test] + fn decodes_and_sanitises_the_last_segment() { + assert_eq!( + file_name(Some("https://example.com/a/K%C3%A4se%3Akuchen.png"), "png"), + "Käse_kuchen.png" + ); + } + + #[test] + fn falls_back_when_there_is_no_usable_name() { + assert_eq!(file_name(None, "png"), "dropped-image.png"); + assert_eq!( + file_name(Some("https://example.com/"), "gif"), + "dropped-image.gif" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b709ab..58ce8a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::Manager; mod account_export; +mod browser_drop; mod contexts; pub mod db; mod e2ee; @@ -569,7 +570,8 @@ pub fn run() { sync::connect_websocket, sync::disconnect_websocket, utils::get_installation_source, - utils::log_frontend_error + utils::log_frontend_error, + browser_drop::read_dropped_image ]) .plugin(tauri_plugin_deep_link::init()) // There is no `.setup()` here on purpose. diff --git a/src/lib/actions/tooltip.ts b/src/lib/actions/tooltip.ts index 232d727..4dfa799 100644 --- a/src/lib/actions/tooltip.ts +++ b/src/lib/actions/tooltip.ts @@ -21,6 +21,13 @@ export interface TooltipOptions { delay?: number; /** Position preference: 'top' | 'bottom' | 'left' | 'right' (default: 'top') */ position?: "top" | "bottom" | "left" | "right"; + /** + * `text` (default) shows the title as one centred block. `list` splits it on newlines: + * the first line is a heading, the last a muted footer, and each line between is a + * left-aligned row - for a title that enumerates several things, which centred + * wrapping turns into one unreadable block. + */ + layout?: "text" | "list"; } /** @@ -35,6 +42,7 @@ export function tooltip( ): { destroy: () => void } { const delay = options.delay ?? 200; let preferredPosition = options.position ?? "top"; + const layout = options.layout ?? "text"; let tooltipEl: HTMLDivElement | null = null; let arrowEl: HTMLDivElement | null = null; @@ -75,9 +83,7 @@ export function tooltip( updateTitle(); // If tooltip is already showing, update its content if (tooltipEl) { - tooltipEl.textContent = title; - // Re-append arrow - if (arrowEl) tooltipEl.appendChild(arrowEl); + renderContent(); positionTooltip(); } } @@ -88,6 +94,28 @@ export function tooltip( observer.observe(element, { attributes: true, attributeFilter: ["title"] }); } + // Fill the tooltip from the title. Built with textContent only, never innerHTML: titles + // carry backend error messages and file names. + function renderContent() { + if (!tooltipEl) return; + tooltipEl.replaceChildren(); + const lines = layout === "list" ? title.split("\n").filter((l) => l.trim()) : []; + if (lines.length < 3) { + tooltipEl.textContent = layout === "list" ? lines.join(" ") : title; + } else { + const add = (cls: string, text: string) => { + const el = document.createElement("div"); + el.className = cls; + el.textContent = text; + tooltipEl!.appendChild(el); + }; + add("tooltip-list-heading", lines[0]); + for (const line of lines.slice(1, -1)) add("tooltip-list-item", line); + add("tooltip-list-footer", lines[lines.length - 1]); + } + if (arrowEl) tooltipEl.appendChild(arrowEl); + } + // Create tooltip element function createTooltip() { // Clean up any orphaned tooltips from previous instances @@ -95,13 +123,12 @@ export function tooltip( existingTooltips.forEach((el) => el.remove()); tooltipEl = document.createElement("div"); - tooltipEl.className = "tooltip"; - tooltipEl.textContent = title; + tooltipEl.className = layout === "list" ? "tooltip tooltip-list" : "tooltip"; tooltipEl.setAttribute("role", "tooltip"); arrowEl = document.createElement("div"); arrowEl.className = "tooltip-arrow"; - tooltipEl.appendChild(arrowEl); + renderContent(); // Check if the element is inside a dialog (top-layer) // If so, append to the dialog to participate in its stacking context diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 61517d3..91ec80d 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -35,6 +35,7 @@ Heading, Maximize2, Minimize2, + TriangleAlert, } from "lucide-svelte"; import { getCaretCoordinates } from "$lib/utils/caret"; import { @@ -57,7 +58,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { onMount, onDestroy, untrack, tick } from "svelte"; - import { findStashAtPosition } from "$lib/stores/drag-state.svelte"; + import { findStashAtPosition, findEditorAtPosition } from "$lib/stores/drag-state.svelte"; import { stat } from "@tauri-apps/plugin-fs"; import { open, message } from "@tauri-apps/plugin-dialog"; import { @@ -69,6 +70,7 @@ import { tooltip } from "$lib/actions/tooltip"; import { resizeImage, getAttachmentKind } from "$lib/utils/files"; import { reportError } from "$lib/utils/error-reporting"; + import { errorText } from "$lib/errors"; import { readFile } from "@tauri-apps/plugin-fs"; let { @@ -173,6 +175,22 @@ let addedAssets = $state<{ id: string; filePath: string }[]>([]); let removedAssets = $state<{ id: string; filePath: string }[]>([]); + // Attachments that could not be added, shown as a warning chip next to the file list. + // Not a dialog: a failed drop must not block typing, and the chip waits until it is + // dismissed or the stash is saved, cleared or cancelled. + let attachmentErrors = $state([]); + + /** Log an attachment failure and surface it on the warning chip. */ + function noteAttachmentError(err: unknown, logContext: string, message: string) { + reportError("attachment", err, logContext); + attachmentErrors = [...attachmentErrors, message]; + } + + /** The chip text for a file that could not be saved. */ + function saveFailedMessage(name: string, err: unknown): string { + return $_("editor.attachmentErrors.saveFailed", { values: { name, reason: errorText(err) } }); + } + // Clear confirmation dialog state let clearConfirmDialogOpen = $state(false); @@ -232,17 +250,28 @@ position: { x: number; y: number }; }>("tauri://drag-drop", async (event) => { dragOver = false; - - // Check if drop is targeting a StashCard - if so, Queue handles it - const targetStashId = findStashAtPosition( - event.payload.position.x, - event.payload.position.y, - ); - if (targetStashId) { - return; // Let Queue.svelte handle this drop + // Every editor hears every drop, so exactly one may take it: the editor it landed + // in, or - for a drop outside every editor and every stash card - the main one. + // A drop on a card outside an editor is Queue.svelte's. + const { x, y } = event.payload.position; + const targetEditor = findEditorAtPosition(x, y); + if (targetEditor) { + if (targetEditor !== rootEl) return; + } else if (existingStashId || findStashAtPosition(x, y)) { + return; } - const paths = event.payload.paths; + // A browser drop carries no file paths: its image is read back from the drag + // and handed over as a scratch file, so it takes the same path as a Finder drop. + let paths = event.payload.paths; + if (paths.length === 0) { + try { + paths = [await adapter.readDroppedImage()]; + } catch (err) { + noteAttachmentError(err, "Failed to read browser drop", errorText(err)); + return; + } + } for (const path of paths) { try { // Check if it's an image and we need to resize @@ -325,7 +354,7 @@ addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } } catch (err) { - reportError("attachment", err, "Failed to save dropped asset"); + noteAttachmentError(err, "Failed to save dropped asset", saveFailedMessage(path.split(/[\\/]/).pop() ?? path, err)); } } }); @@ -553,7 +582,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - reportError("attachment", err, "Failed to save pasted file"); + noteAttachmentError(err, "Failed to save pasted file", saveFailedMessage(file.name, err)); } } return; @@ -619,7 +648,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - reportError("attachment", err, "Failed to save text as attachment"); + noteAttachmentError(err, "Failed to save text as attachment", saveFailedMessage(filename, err)); } } @@ -707,6 +736,7 @@ // Clear tracking arrays after successful save addedAssets = []; removedAssets = []; + attachmentErrors = []; } catch (e) { console.error(e); } finally { @@ -730,6 +760,7 @@ // Clear tracking arrays addedAssets = []; removedAssets = []; + attachmentErrors = []; // Call the original onCancel callback onCancel?.(); @@ -770,6 +801,7 @@ addedAssets = []; removedAssets = []; clearConfirmDialogOpen = false; + attachmentErrors = []; isExpanded = false; } @@ -1041,6 +1073,7 @@ /** * Opens a file picker dialog and adds selected files to the stash. */ + let rootEl = $state(null); let textareaRef = $state(null); function adjustTextareaHeight() { @@ -1135,7 +1168,7 @@ // Track added file for cleanup on cancel addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } catch (err) { - reportError("attachment", err, "Failed to save picked file"); + noteAttachmentError(err, "Failed to save picked file", saveFailedMessage(path.split(/[\\/]/).pop() ?? path, err)); } } } @@ -1157,6 +1190,8 @@ {/if}
{/each} - {#if files.length === 0} + {#if attachmentErrors.length > 0} + + {/if} + {#if files.length === 0 && attachmentErrors.length === 0} {$_("editor.dragFilesHere")} diff --git a/src/lib/components/Queue.svelte b/src/lib/components/Queue.svelte index 74bf699..ab9284c 100644 --- a/src/lib/components/Queue.svelte +++ b/src/lib/components/Queue.svelte @@ -32,7 +32,7 @@ import { setHoveredStash, setDragging, - findStashAtPosition, + findCardDropTarget, } from "$lib/stores/drag-state.svelte"; import { Trash2, @@ -712,7 +712,7 @@ "tauri://drag-enter", (event) => { setDragging(true); - const stashId = findStashAtPosition( + const stashId = findCardDropTarget( event.payload.position.x, event.payload.position.y, ); @@ -723,7 +723,7 @@ unlistenOver = await listen<{ position: { x: number; y: number } }>( "tauri://drag-over", (event) => { - const stashId = findStashAtPosition( + const stashId = findCardDropTarget( event.payload.position.x, event.payload.position.y, ); @@ -740,7 +740,7 @@ paths: string[]; position: { x: number; y: number }; }>("tauri://drag-drop", async (event) => { - const stashId = findStashAtPosition( + const stashId = findCardDropTarget( event.payload.position.x, event.payload.position.y, ); @@ -751,7 +751,16 @@ // Find the stash and add attachments to it const stash = stashes.find((s) => s.id === stashId); if (stash && !stash.completed) { - const paths = event.payload.paths; + // A browser drop carries no file paths; see Editor.svelte + let paths = event.payload.paths; + if (paths.length === 0) { + try { + paths = [await adapter.readDroppedImage()]; + } catch (err) { + reportError("attachment", err, "Failed to read browser drop"); + return; + } + } let newAttachments = [...stash.attachments]; for (const path of paths) { try { diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 673b84d..9d80c01 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -506,6 +506,11 @@ "addFile": "Anhänge hinzufügen", "selectFilesTitle": "Dateien zum Anhängen auswählen", "removeFile": "Datei entfernen", + "attachmentErrors": { + "label": "{count, plural, one {# Anhang konnte} other {# Anhänge konnten}} nicht hinzugefügt werden", + "saveFailed": "{name} konnte nicht hinzugefügt werden: {reason}", + "dismiss": "Zum Ausblenden klicken" + }, "regionLabel": "Stash-Editor", "bold": "Fett", "italic": "Kursiv", @@ -588,6 +593,10 @@ "passphrase_empty": "Die Passphrase darf nicht leer sein.", "no_passphrase": "Auf diesem Rechner ist keine Passphrase eingerichtet.", "locked": "Gib zuerst deine Passphrase ein." + }, + "drop": { + "nothing_usable": "Der abgelegte Inhalt enthält nichts, was Stashpad anhängen kann. Kopiere das Bild stattdessen und füge es ein.", + "download_failed": "Das abgelegte Bild konnte nicht heruntergeladen werden: {reason}" } } } diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 36bf3c5..d32acaf 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -506,6 +506,11 @@ "addFile": "Add attachments", "selectFilesTitle": "Select files to attach", "removeFile": "Remove file", + "attachmentErrors": { + "label": "{count, plural, one {# attachment} other {# attachments}} could not be added", + "saveFailed": "Couldn't add {name}: {reason}", + "dismiss": "Click to dismiss" + }, "regionLabel": "Stash editor", "bold": "Bold", "italic": "Italic", @@ -588,6 +593,10 @@ "passphrase_empty": "The passphrase cannot be empty.", "no_passphrase": "No passphrase is set on this machine.", "locked": "Enter your passphrase first." + }, + "drop": { + "nothing_usable": "This drop contained nothing Stashpad can attach. Try copying the image and pasting it instead.", + "download_failed": "Couldn't download the dropped image: {reason}" } } } diff --git a/src/lib/i18n/locales/zh.json b/src/lib/i18n/locales/zh.json index 9758f07..2818a32 100644 --- a/src/lib/i18n/locales/zh.json +++ b/src/lib/i18n/locales/zh.json @@ -506,6 +506,11 @@ "addFile": "添加附件", "selectFilesTitle": "选择要附加的文件", "removeFile": "移除文件", + "attachmentErrors": { + "label": "{count} 个附件未能添加", + "saveFailed": "无法添加 {name}:{reason}", + "dismiss": "点击关闭" + }, "regionLabel": "暂存项编辑器", "bold": "粗体", "italic": "斜体", @@ -588,6 +593,10 @@ "passphrase_empty": "密码短语不能为空。", "no_passphrase": "这台电脑上未设置密码短语。", "locked": "请先输入你的密码短语。" + }, + "drop": { + "nothing_usable": "拖入的内容中没有可附加的项目。请改为复制图片后粘贴。", + "download_failed": "无法下载拖入的图片:{reason}" } } } diff --git a/src/lib/services/desktop-adapter.ts b/src/lib/services/desktop-adapter.ts index 4ee7eee..5287120 100644 --- a/src/lib/services/desktop-adapter.ts +++ b/src/lib/services/desktop-adapter.ts @@ -153,6 +153,10 @@ export class DesktopStorageAdapter implements IStorageService { await invoke('start_drag', { text, files }); } + async readDroppedImage(): Promise { + return await invoke('read_dropped_image'); + } + /** * Import an asset from an external file path into the cache directory. * Files are organized hierarchically: cache/// diff --git a/src/lib/stores/__tests__/drag-state.test.ts b/src/lib/stores/__tests__/drag-state.test.ts new file mode 100644 index 0000000..f4fb55d --- /dev/null +++ b/src/lib/stores/__tests__/drag-state.test.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { findCardDropTarget, findEditorAtPosition } from '../drag-state.svelte'; + +/** + * A queue card with an editor open on it, beside a plain card - the layout where a drop + * into the card's editor used to land in the main editor as well. + */ +function buildQueue() { + document.body.innerHTML = ` +
+
+
+
+
a stash
+
+ `; +} + +/** jsdom has no layout, so `elementFromPoint` answers with whatever id the test points at. */ +let pointedAt: string | null = null; +let lastPoint: [number, number] | null = null; +const originalElementFromPoint = document.elementFromPoint; + +beforeEach(() => { + buildQueue(); + document.elementFromPoint = (x: number, y: number) => { + lastPoint = [x, y]; + return pointedAt ? document.getElementById(pointedAt) : null; + }; +}); + +afterEach(() => { + document.elementFromPoint = originalElementFromPoint; + pointedAt = null; + lastPoint = null; + vi.unstubAllGlobals(); +}); + +describe('drop targeting', () => { + it('gives a drop inside a card editor to that editor, not to the card', () => { + pointedAt = 'card-text'; + expect(findEditorAtPosition(0, 0)?.id).toBe('card-editor'); + expect(findCardDropTarget(0, 0)).toBeNull(); + }); + + it('gives a drop on a card outside any editor to the card', () => { + pointedAt = 'plain-text'; + expect(findEditorAtPosition(0, 0)).toBeNull(); + expect(findCardDropTarget(0, 0)).toBe('plain'); + }); + + it('finds the main editor for a drop inside it', () => { + pointedAt = 'main-text'; + expect(findEditorAtPosition(0, 0)?.id).toBe('main-editor'); + expect(findCardDropTarget(0, 0)).toBeNull(); + }); + + it('finds neither for a drop outside every editor and card', () => { + pointedAt = 'elsewhere'; + expect(findEditorAtPosition(0, 0)).toBeNull(); + expect(findCardDropTarget(0, 0)).toBeNull(); + }); +}); + +describe('drop coordinates', () => { + function onPlatform(platform: string) { + vi.stubGlobal('navigator', { ...navigator, platform }); + vi.stubGlobal('devicePixelRatio', 2); + } + + it('uses macOS positions as they come - they are already CSS pixels', () => { + onPlatform('MacIntel'); + findEditorAtPosition(204, 417); + expect(lastPoint).toEqual([204, 417]); + }); + + it('scales Windows positions, which are device pixels', () => { + onPlatform('Win32'); + findEditorAtPosition(204, 418); + expect(lastPoint).toEqual([102, 209]); + }); +}); diff --git a/src/lib/stores/drag-state.svelte.ts b/src/lib/stores/drag-state.svelte.ts index b3668be..2f7eacc 100644 --- a/src/lib/stores/drag-state.svelte.ts +++ b/src/lib/stores/drag-state.svelte.ts @@ -62,17 +62,39 @@ export function getIsDragging(): boolean { return isDragging; } +/** Attribute marking an editor's root element, so a drop can tell which editor it hit. */ +const EDITOR_ROOT_ATTR = "data-editor-root"; + +/** + * The element under a drag position. + * + * Tauri types the position as physical pixels but passes on whatever the platform's webview + * reported: device pixels on Windows (WebView2 goes through `ScreenToClient`), but points on + * macOS and logical units on Linux - both already CSS pixels. Dividing by the scale factor + * everywhere put every drop on a 2x Mac at half its real coordinates, up and to the left, + * which is where the main editor sits. + */ +function elementAtPosition(x: number, y: number): Element | null { + const scale = navigator.platform.startsWith("Win") ? window.devicePixelRatio || 1 : 1; + return document.elementFromPoint(x / scale, y / scale); +} + +/** + * The editor root a drop landed in, or null when it landed outside every editor. + * + * Every editor hears every drop - the main one and any opened on a queue card - so this + * is how they agree on exactly one of them taking it. + */ +export function findEditorAtPosition(x: number, y: number): Element | null { + return elementAtPosition(x, y)?.closest(`[${EDITOR_ROOT_ATTR}]`) ?? null; +} + /** * Find the stash ID from a position using elementFromPoint. * Returns null if not over a StashCard. */ export function findStashAtPosition(x: number, y: number): string | null { - // Convert physical pixels to CSS pixels (Tauri provides physical coordinates) - const dpr = window.devicePixelRatio || 1; - const cssX = x / dpr; - const cssY = y / dpr; - - const element = document.elementFromPoint(cssX, cssY); + const element = elementAtPosition(x, y); if (!element) return null; // Walk up the DOM tree to find element with data-stash-id @@ -86,3 +108,11 @@ export function findStashAtPosition(x: number, y: number): string | null { } return null; } + +/** + * The stash card a drop adds straight to: a card under the position, unless the position + * is inside an editor - an editor opened on a card takes its own drops. + */ +export function findCardDropTarget(x: number, y: number): string | null { + return findEditorAtPosition(x, y) ? null : findStashAtPosition(x, y); +} diff --git a/src/lib/styles/tooltip.css b/src/lib/styles/tooltip.css index 2d852fd..9568a30 100644 --- a/src/lib/styles/tooltip.css +++ b/src/lib/styles/tooltip.css @@ -27,6 +27,40 @@ opacity: 1; } +/* `layout: "list"` - a heading, one row per entry, and a muted footer */ +.tooltip.tooltip-list { + max-width: 320px; + padding: 8px 10px; + text-align: left; + font-weight: 400; +} + +.tooltip-list-heading { + font-weight: 600; + margin-bottom: 4px; +} + +.tooltip-list-item { + position: relative; + padding: 4px 0 4px 12px; + border-top: 1px solid color-mix(in srgb, var(--background) 20%, transparent); +} + +.tooltip-list-item::before { + content: "\2022"; + position: absolute; + left: 2px; + color: var(--amber); +} + +.tooltip-list-footer { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--background) 20%, transparent); + font-size: 10px; + opacity: 0.65; +} + /* Tooltip arrow */ .tooltip-arrow { position: absolute; diff --git a/src/lib/types.ts b/src/lib/types.ts index ea46163..78722e0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -193,6 +193,11 @@ export interface IStorageService { * @returns The saved attachment metadata */ saveAssetFromPath(path: string, contextId?: string, stashId?: string, syntax?: string): Promise; + /** + * Turn the image in a browser drop - one that arrived with no file paths - into a + * scratch file, and return its path for the same handling as a dropped file. + */ + readDroppedImage(): Promise; /** * Delete an attachment: its row, and its file once no other row references it. * @param id - The attachment id; empty for one never written to the database From 2de69097386b2d0f9be43686325f5ccb8d9db7d2 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 05:28:06 +0200 Subject: [PATCH 3/7] Log everything to one bounded file, in release builds as well In a bundled app there is no terminal, so console.* and println! went nowhere: 78 frontend calls and 51 backend prints left no trace in the log users send. The log plugin also only started once the app was set up, so settings validation, the database migration and the credential store probe ran before it existed and the keychain code kept a private buffer to replay its own lines. A deferring logger is now installed first thing in run(); it holds early records and replays them into the log plugin once the app handle exists, so nothing started before setup is lost and the keychain buffer is gone. The webview logs through $lib/utils/log, which writes to the console and forwards each line with its level to a log_frontend command (replacing log_frontend_error). Every println! became a log macro at a level matching what it reports. Release builds keep info and up from the app and warn and up from everything else. The file is capped at three files of 512 KB. Dev builds log to the terminal and to stashpad-dev.log, so they no longer mix into the installed app's file, and the CrabNebula devtools plugin is removed because the log crate allows one logger and the two cannot coexist. Checked in a running debug build: early keychain lines and frontend lines both reach the file. The ordering and bounding of early records, the level filter and the frontend logger have unit tests. --- CHANGELOG.md | 8 + src-tauri/Cargo.lock | 670 ++---------------- src-tauri/Cargo.toml | 3 +- src-tauri/src/contexts.rs | 14 +- src-tauri/src/db.rs | 2 +- src-tauri/src/keychain.rs | 120 +--- src-tauri/src/lib.rs | 66 +- src-tauri/src/localkey.rs | 24 +- src-tauri/src/logging.rs | 261 +++++++ src-tauri/src/settings.rs | 34 +- src-tauri/src/stashes.rs | 27 +- src-tauri/src/utils.rs | 40 +- src/App.svelte | 7 +- src/lib/actions/externalLinks.ts | 3 +- src/lib/components/CloudAuthModal.svelte | 5 +- src/lib/components/ContextManager.svelte | 9 +- src/lib/components/Editor.svelte | 23 +- src/lib/components/ExportDialog.svelte | 3 +- src/lib/components/FilePreviewModal.svelte | 11 +- src/lib/components/FilePreviewTooltip.svelte | 5 +- src/lib/components/Header.svelte | 3 +- src/lib/components/ImportDialog.svelte | 5 +- src/lib/components/Queue.svelte | 19 +- src/lib/components/Settings.svelte | 23 +- src/lib/components/StashCard.svelte | 7 +- src/lib/services/ai-service.svelte.ts | 5 +- src/lib/services/cloud-sync.ts | 43 +- src/lib/services/desktop-adapter.ts | 16 +- src/lib/stores/attachment-sync.svelte.ts | 3 +- src/lib/stores/updater.svelte.ts | 3 +- src/lib/types.ts | 6 +- .../utils/__tests__/error-reporting.test.ts | 19 +- src/lib/utils/__tests__/log.test.ts | 74 ++ src/lib/utils/error-reporting.ts | 48 +- src/lib/utils/log.ts | 81 +++ src/main.ts | 10 +- 36 files changed, 687 insertions(+), 1013 deletions(-) create mode 100644 src-tauri/src/logging.rs create mode 100644 src/lib/utils/__tests__/log.test.ts create mode 100644 src/lib/utils/log.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 31116e1..2da7e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ popover, not for the person who wrote the commit. that failed or a drop with nothing to attach. Click it to dismiss it; it does not get in the way of typing +### Changed +- **The log file now records what the app itself reported, and it can no longer grow without + limit.** Until now, what the interface printed and most of what happens while the app + starts never reached the log you send us, because an installed app has nowhere to print + it. Both are written to it now. The log is kept to three files of 512 KB each, so it takes + about 1.5 MB at most however long Stashpad runs. A development build writes its own + `stashpad-dev.log`, so it never mixes into the file from the installed app + ### Fixed - **A file you drop lands where you dropped it.** On a Mac with a Retina display, every drop was placed as if it had landed up and to the left of where it really did. A file dropped into diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fd0c0cb..778e77f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -307,28 +307,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "async-task" version = "4.7.1" @@ -392,51 +370,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" -dependencies = [ - "async-trait", - "axum-core", - "bitflags 1.3.2", - "bytes", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper 0.1.2", - "tower 0.4.13", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "mime", - "rustversion", - "tower-layer", - "tower-service", -] - [[package]] name = "base64" version = "0.21.7" @@ -944,16 +877,6 @@ dependencies = [ "objc", ] -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.59.0", -] - [[package]] name = "combine" version = "4.6.7" @@ -1401,48 +1324,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "devtools-core" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7abafdcb55ff28587b551fd20408c65032c1b7f405e7c2b889f20e3b8289d8f" -dependencies = [ - "async-stream", - "bytes", - "devtools-wire-format", - "futures", - "http 0.2.12", - "hyper 0.14.32", - "log", - "prost-types", - "ringbuf", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tonic", - "tonic-health", - "tonic-web", - "tower 0.4.13", - "tower-http 0.4.4", - "tower-layer", - "tracing", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "devtools-wire-format" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c06ffa9aeb3fb248b41d4e71ab3c0aa89177afc6669459da4320b97a4c77948" -dependencies = [ - "bitflags 2.11.0", - "prost", - "prost-types", - "tonic", - "tracing-core", -] - [[package]] name = "digest" version = "0.10.7" @@ -1955,21 +1836,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1977,7 +1843,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -2045,7 +1910,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2407,25 +2271,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -2437,7 +2282,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http", "indexmap 2.13.0", "slab", "tokio", @@ -2571,17 +2416,6 @@ dependencies = [ "markup5ever 0.38.0", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -2592,17 +2426,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -2610,7 +2433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -2621,8 +2444,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -2632,48 +2455,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" -[[package]] -name = "http-range-header" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2684,9 +2471,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "httparse", "itoa", "pin-project-lite", @@ -2702,8 +2489,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", "rustls", "rustls-pki-types", @@ -2712,18 +2499,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-timeout" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" -dependencies = [ - "hyper 0.14.32", - "pin-project-lite", - "tokio", - "tokio-io-timeout", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -2732,7 +2507,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "native-tls", "tokio", @@ -2750,14 +2525,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "system-configuration", "tokio", "tower-service", @@ -3049,15 +2824,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -3364,18 +3130,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "local-ip-address" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612ed4ea9ce5acfb5d26339302528a5e1e59dfed95e9e11af3c083236ff1d15d" -dependencies = [ - "libc", - "neli", - "thiserror 1.0.69", - "windows-sys 0.48.0", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -3445,27 +3199,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "matches" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "memchr" version = "2.8.0" @@ -3602,31 +3341,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "neli" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" -dependencies = [ - "byteorder", - "libc", - "log", - "neli-proc-macros", -] - -[[package]] -name = "neli-proc-macros" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" -dependencies = [ - "either", - "proc-macro2", - "quote", - "serde", - "syn 1.0.109", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3662,15 +3376,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num" version = "0.4.3" @@ -4464,26 +4169,6 @@ dependencies = [ "siphasher 1.0.2", ] -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4720,38 +4405,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "prost-types" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" -dependencies = [ - "prost", -] - [[package]] name = "ptr_meta" version = "0.1.4" @@ -5047,11 +4700,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-tls", "hyper-util", @@ -5065,11 +4718,11 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-native-tls", - "tower 0.5.3", - "tower-http 0.6.8", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -5087,10 +4740,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -5102,12 +4755,12 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.3", - "tower-http 0.6.8", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -5154,17 +4807,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ringbuf" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" -dependencies = [ - "crossbeam-utils", - "portable-atomic", - "portable-atomic-util", -] - [[package]] name = "rkyv" version = "0.7.46" @@ -5765,15 +5407,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shared_child" version = "1.1.1" @@ -5858,16 +5491,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.2" @@ -5964,7 +5587,6 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-deep-link", - "tauri-plugin-devtools", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-global-shortcut", @@ -6098,12 +5720,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6259,7 +5875,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.4.0", + "http", "http-range", "jni", "libc", @@ -6287,7 +5903,6 @@ dependencies = [ "tauri-utils", "thiserror 2.0.18", "tokio", - "tracing", "tray-icon", "url", "webkit2gtk", @@ -6410,33 +6025,6 @@ dependencies = [ "windows-result 0.3.4", ] -[[package]] -name = "tauri-plugin-devtools" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dceb7bd8d7a19d2feb5f9f27122b620e85819063ab76f34c3f69d1bc29645c" -dependencies = [ - "async-stream", - "bytes", - "cocoa", - "colored", - "devtools-core", - "futures", - "local-ip-address", - "log", - "objc", - "serde", - "serde_json", - "swift-rs", - "tauri", - "tauri-plugin", - "tokio", - "tonic", - "tonic-health", - "tracing", - "tracing-subscriber", -] - [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -6593,7 +6181,7 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures-util", - "http 1.4.0", + "http", "infer", "log", "minisign-verify", @@ -6640,7 +6228,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http 1.4.0", + "http", "jni", "objc2 0.6.4", "objc2-ui-kit", @@ -6663,7 +6251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", - "http 1.4.0", + "http", "jni", "log", "objc2 0.6.4", @@ -6675,7 +6263,6 @@ dependencies = [ "tao", "tauri-runtime", "tauri-utils", - "tracing", "url", "webkit2gtk", "webview2-com", @@ -6697,7 +6284,7 @@ dependencies = [ "dunce", "glob", "html5ever 0.29.1", - "http 1.4.0", + "http", "infer", "json-patch", "kuchikiki", @@ -6825,15 +6412,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "tiff" version = "0.10.3" @@ -6940,26 +6518,16 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-io-timeout" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" -dependencies = [ - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -6986,17 +6554,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -7120,86 +6677,6 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" -[[package]] -name = "tonic" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" -dependencies = [ - "async-stream", - "async-trait", - "axum", - "base64 0.21.7", - "bytes", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-timeout", - "percent-encoding", - "pin-project", - "prost", - "tokio", - "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-health" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f80db390246dfb46553481f6024f0082ba00178ea495dbb99e70ba9a4fafb5e1" -dependencies = [ - "async-stream", - "prost", - "tokio", - "tokio-stream", - "tonic", -] - -[[package]] -name = "tonic-web" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddb2a37b247e6adcb9f239f4e5cefdcc5ed526141a416b943929f13aea2cce" -dependencies = [ - "base64 0.21.7", - "bytes", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "pin-project", - "tokio-stream", - "tonic", - "tower-http 0.4.4", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.8", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower" version = "0.5.3" @@ -7209,30 +6686,12 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", ] -[[package]] -name = "tower-http" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "http-range-header", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -7242,11 +6701,11 @@ dependencies = [ "bitflags 2.11.0", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", ] @@ -7269,7 +6728,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -7293,36 +6751,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -7361,7 +6789,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", "native-tls", @@ -7551,12 +6979,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "value-bag" version = "1.12.0" @@ -8231,15 +7653,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -8684,7 +8097,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http 1.4.0", + "http", "javascriptcore-rs", "jni", "libc", @@ -8702,7 +8115,6 @@ dependencies = [ "soup3", "tao-macros", "thiserror 2.0.18", - "tracing", "url", "webkit2gtk", "webkit2gtk-sys", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 469a74d..0fef3b2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,7 +34,6 @@ dirs = "6.0.0" arboard = "3.6.1" drag = { git = "https://github.com/crabnebula-dev/drag-rs" } tauri-plugin-global-shortcut = "2.3.1" -tauri-plugin-devtools = "2.0.0" tauri-plugin-dialog = "2" window-vibrancy = "0.5" tauri-plugin-window-state = "2.4.1" @@ -68,7 +67,7 @@ regex = "1.12.3" tokio-tungstenite = { version = "0.26.1", features = ["native-tls"] } futures-util = "0.3" urlencoding = "2.1.3" -tokio = { version = "1.50.0", features = ["time"] } +tokio = { version = "1.50.0", features = ["time", "macros"] } # Deflate only: the other backends (zstd, lzma, bzip2) pull in native toolchains we do # not need, and every archive we read or write uses deflate. zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src-tauri/src/contexts.rs b/src-tauri/src/contexts.rs index ebff6b9..a48cfd1 100644 --- a/src-tauri/src/contexts.rs +++ b/src-tauri/src/contexts.rs @@ -28,7 +28,7 @@ pub async fn get_contexts(state: State<'_, Arc>) -> Result Ok(match state.lock_db().get_contexts() { Ok(contexts) => contexts, Err(e) => { - println!("Failed to get contexts: {}", e); + log::error!("Failed to get contexts: {}", e); vec![] } }) @@ -39,7 +39,7 @@ pub async fn save_contexts( state: State<'_, Arc>, contexts: Vec, ) -> Result<(), UiError> { - println!("Saving {} contexts", contexts.len()); + log::debug!("Saving {} contexts", contexts.len()); let mut db = state.lock_db(); let tx_result = db.conn.transaction().and_then(|tx| { for ctx in &contexts { @@ -64,19 +64,19 @@ pub async fn save_contexts( }); if let Err(e) = tx_result { - println!("Failed to save contexts: {}", e); + log::error!("Failed to save contexts: {}", e); } Ok(()) } #[tauri::command] pub async fn save_context(state: State<'_, Arc>, context: Context) -> Result<(), UiError> { - println!("Saving context: {} ({})", context.name, context.id); + log::debug!("Saving context: {} ({})", context.name, context.id); if let Err(e) = state .lock_db() .save_context(&context, WriteOrigin::LocalEdit) { - println!("Failed to save context: {}", e); + log::error!("Failed to save context: {}", e); } Ok(()) } @@ -102,11 +102,11 @@ pub async fn import_contexts( /// the result is the same whichever side the deletion starts on. #[tauri::command] pub async fn delete_context(state: State<'_, Arc>, id: String) -> Result<(), UiError> { - println!("Deleting context: {}", id); + log::info!("Deleting context: {}", id); let mut db = state.lock_db(); match db.delete_context(&id) { Ok(deleted) => crate::stashes::purge_deleted_stash_files(&db, &deleted), - Err(e) => println!("Failed to delete context: {}", e), + Err(e) => log::error!("Failed to delete context: {}", e), } Ok(()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9dc5386..c93ee2f 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -594,7 +594,7 @@ impl DbManager { return Ok(()); } - println!( + log::info!( "Migrating v1 files to attachments for {} stashes...", stashes_to_migrate.len() ); diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs index 657312a..f36910b 100644 --- a/src-tauri/src/keychain.rs +++ b/src-tauri/src/keychain.rs @@ -11,7 +11,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; use crate::utils::get_app_dir; @@ -110,54 +110,20 @@ pub enum KeychainStatus { /// to draw from. static KEYCHAIN_STATUS: OnceLock = OnceLock::new(); -/// What the startup checks said, held until there is somewhere to say it. -/// -/// `probe_keychain` and `localkey::initialize` run before `tauri::Builder`, and the log -/// plugin is registered inside it - so every line they emitted went to a logger that did not -/// exist yet and was dropped. That is why a user's log file has never contained a word about -/// the credential store, and why raising those lines from `debug` to `warn` changed nothing. -/// -/// They are buffered here and replayed by [`flush_early_diagnostics`] from the setup hook. -static EARLY_DIAGNOSTICS: Mutex> = Mutex::new(Vec::new()); - -/// Record a line that happens before the logger exists. -pub(crate) fn early_log(level: log::Level, message: String) { - if let Ok(mut buffered) = EARLY_DIAGNOSTICS.lock() { - // Bounded: a pathological loop must not turn a diagnostic into a leak. - if buffered.len() < 64 { - buffered.push((level, message)); - } - } -} - -/// Replay the startup diagnostics, once the log plugin is up. -pub fn flush_early_diagnostics() { - let Ok(mut buffered) = EARLY_DIAGNOSTICS.lock() else { - return; - }; - for (level, message) in buffered.drain(..) { - log::log!(level, "{}", message); - } -} - /// Round-trip a canary through the credential store and remember the outcome. /// /// Call once, early in startup, **before** anything reads a secret. The canary exists so /// the probe never writes a real secret: a probe that stored the cloud token to find out /// whether storing works is how you lose the cloud token. /// -/// Its findings go through [`early_log`], because this runs before the log plugin exists. +/// This runs before the log plugin exists; its findings are held by `logging` and reach the +/// file once the app is set up. pub fn probe_keychain() -> KeychainStatus { let status = run_probe(); match status { - KeychainStatus::Working => early_log( - log::Level::Info, - "Credential store is available and round-trips".to_string(), - ), - KeychainStatus::Unavailable => early_log( - log::Level::Warn, + KeychainStatus::Working => log::info!("Credential store is available and round-trips"), + KeychainStatus::Unavailable => log::warn!( "No usable credential store on this machine - secrets fall back to an encrypted file" - .to_string(), ), } let _ = KEYCHAIN_STATUS.set(status); @@ -170,11 +136,6 @@ pub fn probe_keychain() -> KeychainStatus { /// Each failure path says which step failed, because the outcome alone ("no usable /// credential store") is not something anyone can act on - a machine that wrongly falls /// back to the passphrase prompt is diagnosable only if the reason is visible. -/// -/// They go through [`early_log`] rather than `log::` directly. Raising them from `debug` -/// to `warn` was not enough on its own: this whole function runs before the log plugin is -/// registered, so every line went to a logger that did not exist and no level would have -/// saved it. fn run_probe() -> KeychainStatus { // Unique per probe, because the probe deletes what it wrote. // @@ -201,19 +162,13 @@ fn run_probe() -> KeychainStatus { let entry = match build_entry(&target, KEYCHAIN_SERVICE, &probe_user) { Ok(entry) => entry, Err(e) => { - early_log( - log::Level::Warn, - format!("Credential store probe could not create an entry: {}", e), - ); + log::warn!("Credential store probe could not create an entry: {}", e); return KeychainStatus::Unavailable; } }; if let Err(e) = entry.set_password(&canary) { - early_log( - log::Level::Warn, - format!("Credential store probe could not write: {}", e), - ); + log::warn!("Credential store probe could not write: {}", e); return KeychainStatus::Unavailable; } @@ -229,17 +184,11 @@ fn run_probe() -> KeychainStatus { match readback { Ok(value) if value == canary => KeychainStatus::Working, Ok(_) => { - early_log( - log::Level::Warn, - "Credential store probe read back a different value".to_string(), - ); + log::warn!("Credential store probe read back a different value"); KeychainStatus::Unavailable } Err(e) => { - early_log( - log::Level::Warn, - format!("Credential store probe could not read back: {}", e), - ); + log::warn!("Credential store probe could not read back: {}", e); KeychainStatus::Unavailable } } @@ -448,57 +397,6 @@ fn legacy_deobfuscate(encoded: &str) -> String { mod tests { use super::*; - /// Serialises the two tests below, which share one process-global buffer. - /// - /// Cargo runs tests on parallel threads in a single process, so without this they - /// clear and push to the same `EARLY_DIAGNOSTICS` and each sees the other's lines: the - /// count test read 5 where it expected 2, and the bound test had its accumulation wiped - /// mid-loop. It passed twice and failed on the third run of the same commit, which is - /// what a race looks like from the outside. - /// - /// `lock_or_recover` rather than `.lock().unwrap()`, matching `e2ee_session`: one - /// failing test would otherwise poison the mutex and every later one would panic on - /// the lock instead of running. - static EARLY_TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn early_diagnostics_are_held_and_then_handed_over_once() { - let _guard = crate::state::lock_or_recover(&EARLY_TEST_LOCK); - // The whole point of the buffer is that these lines survive being emitted before - // the log plugin exists. If draining stopped working they would go quiet again, - // which is exactly the failure that made the credential store undiagnosable. - EARLY_DIAGNOSTICS.lock().expect("lock").clear(); - - early_log(log::Level::Warn, "first".to_string()); - early_log(log::Level::Info, "second".to_string()); - assert_eq!(EARLY_DIAGNOSTICS.lock().expect("lock").len(), 2); - - flush_early_diagnostics(); - assert!( - EARLY_DIAGNOSTICS.lock().expect("lock").is_empty(), - "a flush must hand the lines over, not copy them" - ); - - // A second flush is a no-op rather than a repeat, since setup can run again. - flush_early_diagnostics(); - assert!(EARLY_DIAGNOSTICS.lock().expect("lock").is_empty()); - } - - #[test] - fn the_early_buffer_does_not_grow_without_bound() { - let _guard = crate::state::lock_or_recover(&EARLY_TEST_LOCK); - EARLY_DIAGNOSTICS.lock().expect("lock").clear(); - for i in 0..200 { - early_log(log::Level::Warn, format!("line {}", i)); - } - assert_eq!( - EARLY_DIAGNOSTICS.lock().expect("lock").len(), - 64, - "startup diagnostics are bounded; a loop must not turn them into a leak" - ); - EARLY_DIAGNOSTICS.lock().expect("lock").clear(); - } - /// Builds a value in the format older builds wrote. Only the tests need this now - /// nothing in the app seals under the machine key any more. fn seal_under_machine_key(secret: &str) -> String { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58ce8a3..0ca8f15 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod e2ee_session; mod envelope; mod keychain; mod localkey; +mod logging; mod models; mod settings; mod stashes; @@ -144,9 +145,8 @@ impl ContextMatcher { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // 0. init devtools - #[cfg(debug_assertions)] // only enable instrumentation in development builds - let devtools = tauri_plugin_devtools::init(); + // 0. Before anything can log: everything up to the setup hook is held for the file. + logging::install(); // 1. Initialize Storage ensure_storage_ready(); @@ -254,9 +254,9 @@ pub fn run() { let settings_state_for_setup = settings_state.clone(); let settings_state_for_unlock = settings_state.clone(); - let mut builder = tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { - println!("Single instance triggered: argv={:?}, cwd={:?}", argv, cwd); + log::info!("Single instance triggered: argv={:?}, cwd={:?}", argv, cwd); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); @@ -264,10 +264,27 @@ pub fn run() { } })) .setup(move |app| { - // Everything the credential store and the device key had to say happened before - // the log plugin existed, so it went nowhere. Replayed here, where it lands in - // the file people can actually send. - crate::keychain::flush_early_diagnostics(); + // The log file needs the app's log folder, which only exists from here on. + // Everything logged before now - settings validation, the database migration, + // the credential store probe - was held by `logging` and is replayed into it. + let (log_plugin, max_level, logger) = tauri_plugin_log::Builder::default() + .level(logging::OTHER_LEVEL) + .level_for("app_lib", logging::APP_LEVEL) + .max_file_size(logging::MAX_FILE_BYTES) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome( + logging::KEEP_ARCHIVED, + )) + .targets([ + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + // Dev runs get their own file, so they never mix into the log a user + // of the installed app would send. + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { + file_name: cfg!(debug_assertions).then(|| "stashpad-dev".to_string()), + }), + ]) + .split(app.handle())?; + logging::attach(logger, max_level); + app.handle().plugin(log_plugin)?; // Open this installation's copy of the content key, if there is one. // @@ -413,31 +430,6 @@ pub fn run() { .manage(settings_state) .manage(ws_state); - #[cfg(debug_assertions)] - { - builder = builder.plugin(devtools); - } - - #[cfg(not(debug_assertions))] - { - // The defaults log at TRACE, and Tauri's own `tracing` spans mean every IPC call - // writes about thirty lines. With the default 40 KB cap and a rotation that keeps - // one file, the log wiped itself roughly every five seconds of ordinary use - so - // `log_frontend_error`, added precisely so a webview error that wedges the UI - // leaves a trace on disk, was always gone before anyone could read it. It also - // meant a continuous few KB a second of synchronous disk writes on the IPC thread. - // - // Only this crate's own records are kept, and the file is given room to hold a - // session's worth of them. - builder = builder.plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Warn) - .level_for("app_lib", log::LevelFilter::Info) - .max_file_size(512_000) - .build(), - ); - } - builder .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) @@ -570,7 +562,7 @@ pub fn run() { sync::connect_websocket, sync::disconnect_websocket, utils::get_installation_source, - utils::log_frontend_error, + logging::log_frontend, browser_drop::read_dropped_image ]) .plugin(tauri_plugin_deep_link::init()) @@ -587,7 +579,7 @@ pub fn run() { .expect("error while building tauri application") .run(|app_handle, event| { if let tauri::RunEvent::Exit = event { - println!("App exiting, cleaning up..."); + log::info!("App exiting, cleaning up..."); cleanup_websocket_state(app_handle); cleanup_database_state(app_handle); } @@ -632,7 +624,7 @@ fn cleanup_database_state(app_handle: &tauri::AppHandle) { match db_arc.try_lock() { Ok(db) => { let _: rusqlite::Result<()> = db.prepare_shutdown(); - println!("DB shutdown successful (WAL checkpointed)."); + log::info!("DB shutdown successful (WAL checkpointed)."); return; } Err(std::sync::TryLockError::Poisoned(poisoned)) => { diff --git a/src-tauri/src/localkey.rs b/src-tauri/src/localkey.rs index 14484f2..352010f 100644 --- a/src-tauri/src/localkey.rs +++ b/src-tauri/src/localkey.rs @@ -315,10 +315,8 @@ fn load_or_create_machine_key() -> bool { } // A malformed entry is not something to silently replace: the device key file was // sealed with whatever the old value was, so overwriting it strands that file. - crate::keychain::early_log( - log::Level::Error, + log::error!( "The local key in the credential store is not 32 bytes; refusing to replace it" - .to_string(), ); return false; } @@ -338,10 +336,7 @@ fn load_or_create_machine_key() -> bool { } *lock_or_recover(&LOCAL_KEY) = Some(key); - crate::keychain::early_log( - log::Level::Info, - "Created this installation's local key in the credential store".to_string(), - ); + log::info!("Created this installation's local key in the credential store"); true } @@ -353,10 +348,7 @@ fn try_remembered() -> bool { let Some(bytes) = open_with(&crate::keychain::derive_machine_key(), wrapped.trim()) else { // Usually means the folder moved or the machine was renamed, which changes the // machine key. The passphrase still works, so ask for it rather than failing. - crate::keychain::early_log( - log::Level::Warn, - "The remembered device key could not be opened; asking for the passphrase".to_string(), - ); + log::warn!("The remembered device key could not be opened; asking for the passphrase"); return false; }; if bytes.len() != 32 { @@ -405,19 +397,13 @@ pub fn initialize() -> LocalKeyStatus { // The probe said the store works, so a failure here is an error rather than a cue to // write the key somewhere weaker. Fall through and let the passphrase path offer // itself, which at least tells the user something is wrong. - crate::keychain::early_log( - log::Level::Error, - "The credential store passed its probe but would not hold the local key".to_string(), - ); + log::error!("The credential store passed its probe but would not hold the local key"); } if !is_configured() { return LocalKeyStatus::Unset; } if try_remembered() { - crate::keychain::early_log( - log::Level::Info, - "Device key restored from the remembered copy on this machine".to_string(), - ); + log::info!("Device key restored from the remembered copy on this machine"); return LocalKeyStatus::Unlocked; } LocalKeyStatus::Locked diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 0000000..58821a8 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +//! The one logger for the whole process, backend and webview alike. +//! +//! A bundled app has no terminal, so `println!` and the webview console go nowhere in a +//! release build; the log file is the only record there is. Everything therefore goes +//! through `log::`, and the webview reaches it through [`log_frontend`]. +//! +//! The log plugin needs an `AppHandle` to find its folder, so it only exists once the app +//! is set up - but settings validation, the database migration and the credential store +//! probe all run before that. A logger installed then would be dropped, and the `log` +//! crate allows exactly one. So [`install`] puts a deferring logger in place as the first +//! thing `run()` does: it holds early records until [`attach`] hands it the plugin's +//! logger, replays them into it, and forwards everything after. +//! +//! The file is bounded: [`MAX_FILE_BYTES`] per file and [`KEEP_ARCHIVED`] older files +//! beside the active one, so about 1.5 MB at most however long the app runs. + +use std::sync::{Mutex, OnceLock}; + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// Size at which the active log file is archived and a new one started. +pub const MAX_FILE_BYTES: u128 = 512_000; + +/// Archived files kept beside the active one. +pub const KEEP_ARCHIVED: usize = 2; + +/// This crate's records: everything in a dev build, info and up in a release build. +pub const APP_LEVEL: LevelFilter = if cfg!(debug_assertions) { + LevelFilter::Debug +} else { + LevelFilter::Info +}; + +/// Everyone else's - Tauri and its plugins log every IPC call at trace, which once made +/// the file wipe itself every few seconds. +pub const OTHER_LEVEL: LevelFilter = LevelFilter::Warn; + +/// Early records held at most. A loop at startup must not turn the buffer into a leak. +const EARLY_LIMIT: usize = 500; + +/// Whether a record passes the same filter the file applies. +fn wanted(metadata: &Metadata) -> bool { + let limit = if metadata.target().starts_with("app_lib") { + APP_LEVEL + } else { + OTHER_LEVEL + }; + metadata.level() <= limit +} + +struct EarlyRecord { + level: Level, + target: String, + message: String, +} + +/// Holds records until the real logger exists, then forwards to it. +struct Deferred { + inner: OnceLock>, + early: Mutex>, +} + +impl Log for Deferred { + fn enabled(&self, metadata: &Metadata) -> bool { + match self.inner.get() { + Some(inner) => inner.enabled(metadata), + None => wanted(metadata), + } + } + + fn log(&self, record: &Record) { + // The lock orders this against `attach`: a record either lands in the buffer + // before the replay, or sees the inner logger after it - never between the two. + let Ok(mut early) = self.early.lock() else { + return; + }; + if let Some(inner) = self.inner.get() { + drop(early); + inner.log(record); + } else if wanted(record.metadata()) && early.len() < EARLY_LIMIT { + early.push(EarlyRecord { + level: record.level(), + target: record.target().to_string(), + message: record.args().to_string(), + }); + } + } + + fn flush(&self) { + if let Some(inner) = self.inner.get() { + inner.flush(); + } + } +} + +impl Deferred { + const fn new() -> Self { + Self { + inner: OnceLock::new(), + early: Mutex::new(Vec::new()), + } + } + + /// Take the real logger and replay the held records into it. False if it already had one. + fn hand_over(&self, inner: Box) -> bool { + let Ok(mut early) = self.early.lock() else { + return false; + }; + if self.inner.set(inner).is_err() { + return false; + } + let inner = self.inner.get().expect("just set"); + for record in early.drain(..) { + inner.log( + &Record::builder() + .level(record.level) + .target(&record.target) + .args(format_args!("{}", record.message)) + .build(), + ); + } + true + } +} + +static LOGGER: Deferred = Deferred::new(); + +/// Install the deferring logger. Call first thing in `run()`, before anything can log. +pub fn install() { + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(APP_LEVEL.max(OTHER_LEVEL)); + } +} + +/// Hand over the real logger and replay what was held for it. +/// +/// Early records are written with the time of the replay rather than of the event; they +/// all come from the first moments of startup, so the order is what matters, and that is +/// kept. +pub fn attach(inner: Box, max_level: LevelFilter) { + if LOGGER.hand_over(inner) { + log::set_max_level(max_level); + } +} + +/// A line from the webview's logger (`$lib/utils/log.ts`). +/// +/// Capped in length, because the webview can hand over anything, including a stack that +/// runs to megabytes, and one such line would otherwise push the whole session out of the +/// file. Truncated by character, not byte, so a multi-byte stack cannot panic the command. +#[tauri::command] +pub fn log_frontend(level: String, message: String) { + const MAX_CHARS: usize = 4000; + let level = match level.as_str() { + "error" => Level::Error, + "warn" => Level::Warn, + "info" => Level::Info, + _ => Level::Debug, + }; + let trimmed: String = message.chars().take(MAX_CHARS).collect(); + let elided = message.chars().nth(MAX_CHARS).is_some(); + log::log!( + target: "app_lib::frontend", + level, + "{}{}", + trimmed, + if elided { " […]" } else { "" } + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(level: Level, target: &str) -> Metadata<'_> { + Metadata::builder().level(level).target(target).build() + } + + #[test] + fn keeps_app_records_at_the_app_level() { + assert!(wanted(&metadata(Level::Info, "app_lib::sync"))); + assert!(wanted(&metadata(Level::Error, "app_lib::frontend"))); + assert_eq!( + wanted(&metadata(Level::Debug, "app_lib::sync")), + cfg!(debug_assertions), + "debug records are for dev builds only" + ); + } + + #[test] + fn keeps_only_warnings_from_everyone_else() { + assert!(wanted(&metadata(Level::Warn, "tauri::ipc"))); + assert!(!wanted(&metadata(Level::Info, "tauri::ipc"))); + assert!(!wanted(&metadata(Level::Trace, "tauri::ipc"))); + } + + /// Counts what reaches it, standing in for the plugin's logger. + struct Collect(std::sync::Arc>>); + + impl Log for Collect { + fn enabled(&self, _: &Metadata) -> bool { + true + } + fn log(&self, record: &Record) { + self.0.lock().unwrap().push(record.args().to_string()); + } + fn flush(&self) {} + } + + #[test] + fn holds_early_records_and_replays_them_in_order_once_attached() { + let deferred = Deferred::new(); + let log_at = |level, target: &str, message: &str| { + deferred.log( + &Record::builder() + .level(level) + .target(target) + .args(format_args!("{}", message)) + .build(), + ) + }; + + log_at(Level::Warn, "app_lib::keychain", "first"); + log_at(Level::Trace, "tauri::ipc", "noise"); + log_at(Level::Info, "app_lib::settings", "second"); + for _ in 0..EARLY_LIMIT + 50 { + log_at(Level::Warn, "app_lib::loop", "flood"); + } + assert_eq!( + deferred.early.lock().unwrap().len(), + EARLY_LIMIT, + "early records are bounded and filtered" + ); + + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + assert!(deferred.hand_over(Box::new(Collect(seen.clone())))); + assert!( + !deferred.hand_over(Box::new(Collect(seen.clone()))), + "a second logger is refused rather than replacing the first" + ); + log_at(Level::Info, "app_lib::later", "after"); + + let seen = seen.lock().unwrap(); + assert_eq!(&seen[..2], ["first", "second"]); + assert_eq!(seen.last().map(String::as_str), Some("after")); + assert!(deferred.early.lock().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d9b3cbe..cc7938d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -95,9 +95,10 @@ pub fn validate_settings(mut settings: Settings) -> Settings { // Validate new_stash_position: must be "top" or "bottom" if settings.new_stash_position != "top" && settings.new_stash_position != "bottom" { - println!( - "Warning: Invalid new_stash_position '{}', defaulting to '{}'", - settings.new_stash_position, defaults.new_stash_position + log::warn!( + "Invalid new_stash_position '{}', defaulting to '{}'", + settings.new_stash_position, + defaults.new_stash_position ); settings.new_stash_position = defaults.new_stash_position.clone(); } @@ -105,9 +106,10 @@ pub fn validate_settings(mut settings: Settings) -> Settings { // Validate clear_completed_strategy: must be "never", "on-close", or "after-n-days" let valid_strategies = ["never", "on-close", "after-n-days"]; if !valid_strategies.contains(&settings.clear_completed_strategy.as_str()) { - println!( - "Warning: Invalid clear_completed_strategy '{}', defaulting to '{}'", - settings.clear_completed_strategy, defaults.clear_completed_strategy + log::warn!( + "Invalid clear_completed_strategy '{}', defaulting to '{}'", + settings.clear_completed_strategy, + defaults.clear_completed_strategy ); settings.clear_completed_strategy = defaults.clear_completed_strategy.clone(); } @@ -115,18 +117,15 @@ pub fn validate_settings(mut settings: Settings) -> Settings { // Validate theme: must be "light", "dark", "system", or None if let Some(ref theme) = settings.theme { if !["light", "dark", "system"].contains(&theme.as_str()) { - println!( - "Warning: Invalid theme '{}', defaulting to None (system)", - theme - ); + log::warn!("Invalid theme '{}', defaulting to None (system)", theme); settings.theme = None; } } // Validate clear_completed_days: must be at least 1 if strategy is after-n-days if settings.clear_completed_strategy == "after-n-days" && settings.clear_completed_days == 0 { - println!( - "Warning: clear_completed_days is 0 with after-n-days strategy, defaulting to {}", + log::warn!( + "clear_completed_days is 0 with after-n-days strategy, defaulting to {}", defaults.clear_completed_days ); settings.clear_completed_days = defaults.clear_completed_days; @@ -136,9 +135,10 @@ pub fn validate_settings(mut settings: Settings) -> Settings { // end. Clamp rather than reset - the user asked for "as large as possible", and // dropping them back to the default silently discards that intent. if settings.paste_as_attachment_threshold > MAX_PASTE_AS_ATTACHMENT_THRESHOLD { - println!( - "Warning: paste_as_attachment_threshold {} is too high, clamping to {}", - settings.paste_as_attachment_threshold, MAX_PASTE_AS_ATTACHMENT_THRESHOLD + log::warn!( + "paste_as_attachment_threshold {} is too high, clamping to {}", + settings.paste_as_attachment_threshold, + MAX_PASTE_AS_ATTACHMENT_THRESHOLD ); settings.paste_as_attachment_threshold = MAX_PASTE_AS_ATTACHMENT_THRESHOLD; } @@ -149,14 +149,14 @@ pub fn validate_settings(mut settings: Settings) -> Settings { let now_ms = crate::time::now_ms(); let horizon = now_ms.saturating_add(24 * 60 * 60 * 1000); if settings.last_update_check_at.is_some_and(|t| t > horizon) { - println!("Warning: last_update_check_at is in the future, resetting"); + log::warn!("last_update_check_at is in the future, resetting"); settings.last_update_check_at = None; } if settings .update_remind_after .is_some_and(|t| t > horizon + 7 * 24 * 60 * 60 * 1000) { - println!("Warning: update_remind_after is implausibly far ahead, resetting"); + log::warn!("update_remind_after is implausibly far ahead, resetting"); settings.update_remind_after = None; } diff --git a/src-tauri/src/stashes.rs b/src-tauri/src/stashes.rs index a6ed90b..6a070c0 100644 --- a/src-tauri/src/stashes.rs +++ b/src-tauri/src/stashes.rs @@ -142,7 +142,7 @@ pub async fn save_stash( calculate_stash_update(&stash, existing.as_ref(), effective_position_str, min_pos); if let Err(e) = db.save_stash(&new_stash, position_val, WriteOrigin::LocalEdit) { - println!("Failed to save stash: {}", e); + log::error!("Failed to save stash: {}", e); } Ok(()) } @@ -209,7 +209,7 @@ pub async fn delete_stash(state: State<'_, Arc>, id: String) -> Result< // delete directory recursively if stash_path.exists() { if let Err(e) = fs::remove_dir_all(&stash_path) { - println!("Failed to delete stash attachments: {}", e); + log::error!("Failed to delete stash attachments: {}", e); } } @@ -223,7 +223,7 @@ pub async fn delete_stash(state: State<'_, Arc>, id: String) -> Result< } if let Err(e) = db.delete_stash(&id) { - println!("Failed to delete stash from DB: {}", e); + log::error!("Failed to delete stash from DB: {}", e); } Ok(()) } @@ -477,10 +477,10 @@ pub async fn save_stashes( stashes_list: Vec, ) -> Result<(), UiError> { // This is used for REORDERING, which rewrites a row per visible stash. - println!("Saving stash order ({} items)", stashes_list.len()); + log::debug!("Saving stash order ({} items)", stashes_list.len()); let mut db = state.lock_db(); if let Err(e) = db.update_stash_positions(&stashes_list) { - println!("Failed to update stash positions: {}", e); + log::error!("Failed to update stash positions: {}", e); } Ok(()) } @@ -700,9 +700,11 @@ pub async fn save_asset_from_path( stash_id: Option, syntax: Option, ) -> Result { - println!( + log::info!( "Importing asset from path: {} context: {:?} stash: {:?}", - path, context_id, stash_id + path, + context_id, + stash_id ); let source_path = std::path::Path::new(&path); if !source_path.exists() { @@ -799,7 +801,7 @@ pub async fn save_asset_from_path( ); if let Err(e) = res { - println!( + log::warn!( "Failed to save attachment metadata (likely due to missing stash parent): {}", e ); @@ -836,7 +838,7 @@ pub async fn delete_asset( id: Option, path: String, ) -> Result<(), UiError> { - println!("Deleting asset: {}", path); + log::info!("Deleting asset: {}", path); let file_path = std::path::Path::new(&path); @@ -885,9 +887,10 @@ pub async fn delete_asset( }; if still_referenced > 0 { - println!( + log::info!( "Kept {} - {} other attachment(s) still reference it", - path, still_referenced + path, + still_referenced ); return Ok(()); } @@ -896,7 +899,7 @@ pub async fn delete_asset( fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?; } - println!("Successfully deleted asset: {}", path); + log::info!("Successfully deleted asset: {}", path); Ok(()) } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index f4ce77d..23982f1 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -314,20 +314,20 @@ pub fn apply_window_effects_to_window( // Try Mica first (Windows 11) - Mica respects system theme automatically match apply_mica(window, Some(is_dark)) { Ok(_) => { - println!("Applied Mica effect (Windows 11, dark={})", is_dark); + log::debug!("Applied Mica effect (Windows 11, dark={})", is_dark); } Err(_) => { // Mica not available (Windows 10 or earlier), try Acrylic - println!( + log::debug!( "Mica not available, trying Acrylic (Windows 10, dark={})…", is_dark ); match apply_acrylic(window, Some(acrylic_color)) { Ok(_) => { - println!("Applied Acrylic effect (Windows 10, dark={})", is_dark); + log::debug!("Applied Acrylic effect (Windows 10, dark={})", is_dark); } Err(e) => { - println!("Failed to apply Acrylic effect: {:?}", e); + log::warn!("Failed to apply Acrylic effect: {:?}", e); // Fall back to transparent window without effects } } @@ -339,16 +339,16 @@ pub fn apply_window_effects_to_window( { // Apply vibrancy with a dark appearance if let Err(e) = apply_vibrancy(window, NSVisualEffectMaterial::HudWindow, None, None) { - println!("Failed to apply vibrancy effect: {:?}", e); + log::warn!("Failed to apply vibrancy effect: {:?}", e); } else { - println!("Applied vibrancy effect (macOS)"); + log::debug!("Applied vibrancy effect (macOS)"); } } // Linux: No window-vibrancy support, transparency handled by compositor #[cfg(target_os = "linux")] { - println!("Linux: Window transparency is handled by the compositor"); + log::debug!("Linux: Window transparency is handled by the compositor"); } } else { // Clear effects for opaque background @@ -357,19 +357,19 @@ pub fn apply_window_effects_to_window( // Try to clear both effects (one will succeed based on what was applied) let _ = clear_mica(window); let _ = clear_acrylic(window); - println!("Cleared window effects (Windows)"); + log::debug!("Cleared window effects (Windows)"); } #[cfg(target_os = "macos")] { // On macOS, vibrancy can't be easily cleared programmatically, // but the CSS will show an opaque background when effects are disabled - println!("macOS: Visual effects disabled (CSS will handle opaque background)"); + log::debug!("macOS: Visual effects disabled (CSS will handle opaque background)"); } #[cfg(target_os = "linux")] { - println!("Linux: Visual effects disabled"); + log::debug!("Linux: Visual effects disabled"); } } } @@ -611,7 +611,7 @@ pub async fn read_clipboard_text() -> Result { /// the drag call is kept minimal. #[tauri::command] pub fn start_drag(window: tauri::Window, text: String, files: Vec) -> Result<(), UiError> { - println!("Starting drag with {} files", files.len()); + log::debug!("Starting drag with {} files", files.len()); let items = if !files.is_empty() { let paths: Vec = files.iter().map(PathBuf::from).collect(); @@ -635,7 +635,7 @@ pub fn start_drag(window: tauri::Window, text: String, files: Vec) -> Re let temp_path = cache_dir.join(filename); if let Err(e) = fs::write(&temp_path, &text) { - println!("Failed to write temp drag file: {}", e); + log::error!("Failed to write temp drag file: {}", e); return Err("Failed to create drag data".into()); } @@ -962,22 +962,6 @@ pub fn get_previous_app_info(state: State>>) -> AppConte } } -/// Record an error the webview could not handle itself. -/// -/// In a release build the webview console is discarded, so a render error that killed -/// the UI left no trace anywhere. Routing it through `log::error!` puts it in the same -/// app log as backend failures, which is the only place a user can be asked to look. -/// -/// The message is truncated by character, not byte, so a multi-byte stack trace cannot -/// panic the command - the same mistake that used to kill the sync commands. -#[tauri::command] -pub fn log_frontend_error(message: String) { - const MAX_CHARS: usize = 4000; - let trimmed: String = message.chars().take(MAX_CHARS).collect(); - let elided = message.chars().nth(MAX_CHARS).is_some(); - log::error!("[frontend] {}{}", trimmed, if elided { " […]" } else { "" }); -} - #[cfg(test)] mod reserve_unique_path_tests { use super::reserve_unique_path; diff --git a/src/App.svelte b/src/App.svelte index 108feeb..85b6c13 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -47,6 +47,7 @@ import { errorText } from "$lib/errors"; import { nowIso, nowMs } from "$lib/utils/time"; + import { log } from "$lib/utils/log"; /** * Hand a caught render error to the reporter. * @@ -524,7 +525,7 @@ } refreshTrigger++; } catch (e) { - console.error("Failed to move/copy stash", e); + log.error("Failed to move/copy stash", e); } } @@ -576,7 +577,7 @@ try { contexts = await adapter.getContexts(); } catch (e) { - console.error("Failed to load contexts", e); + log.error("Failed to load contexts", e); } } @@ -587,7 +588,7 @@ const loaded = await adapter.getSettings(); applySettings(loaded); } catch (e) { - console.error(e); + log.error(e); } } diff --git a/src/lib/actions/externalLinks.ts b/src/lib/actions/externalLinks.ts index 042a564..75c99ab 100644 --- a/src/lib/actions/externalLinks.ts +++ b/src/lib/actions/externalLinks.ts @@ -13,6 +13,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; +import { log } from "$lib/utils/log"; /** * Check whether the given URL string is an external link (http/https). */ @@ -73,7 +74,7 @@ export function externalLinks(node: HTMLElement) { // Open in default browser only when Cmd (macOS) / Ctrl (Win/Linux) is held if (e.metaKey || e.ctrlKey) { openUrl(href).catch((err) => { - console.error("Failed to open external URL:", href, err); + log.error("Failed to open external URL:", href, err); }); } } diff --git a/src/lib/components/CloudAuthModal.svelte b/src/lib/components/CloudAuthModal.svelte index 83bc805..b98df25 100644 --- a/src/lib/components/CloudAuthModal.svelte +++ b/src/lib/components/CloudAuthModal.svelte @@ -31,6 +31,7 @@ import type { Settings } from "$lib/types"; import { errorText } from "$lib/errors"; + import { log } from "$lib/utils/log"; let { open = $bindable(false), settings = $bindable(), @@ -99,7 +100,7 @@ */ function openBrowser(): void { openUrl(getAccountUrl()).catch((err) => { - console.error("[CloudAuthModal] Failed to open browser:", err); + log.error("[CloudAuthModal] Failed to open browser:", err); }); } @@ -181,7 +182,7 @@ exchangeLinkCode(); } } catch (err) { - console.error( + log.error( "[CloudAuthModal] Failed to parse deep link URL:", err, ); diff --git a/src/lib/components/ContextManager.svelte b/src/lib/components/ContextManager.svelte index d762920..9609bcb 100644 --- a/src/lib/components/ContextManager.svelte +++ b/src/lib/components/ContextManager.svelte @@ -17,6 +17,7 @@ import ContextManagerItem from "./ContextManagerItem.svelte"; + import { log } from "$lib/utils/log"; let { onBack, onSelect } = $props<{ onBack: () => void; onSelect?: (id: string) => void; @@ -111,7 +112,7 @@ stashTotals = totals; contextSizes = sizes; } catch (e) { - console.error("Failed to load contexts", e); + log.error("Failed to load contexts", e); } finally { isLoading = false; } @@ -121,7 +122,7 @@ try { await adapter.saveContexts(contexts); } catch (e) { - console.error("Failed to save contexts", e); + log.error("Failed to save contexts", e); } } @@ -133,7 +134,7 @@ try { await adapter.saveContext(context); } catch (e) { - console.error("Failed to save context", e); + log.error("Failed to save context", e); } } @@ -168,7 +169,7 @@ delete stashTotals[id]; delete contextSizes[id]; } catch (e) { - console.error("Failed to delete context:", e); + log.error("Failed to delete context:", e); } } diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 91ec80d..81495d8 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -73,6 +73,7 @@ import { errorText } from "$lib/errors"; import { readFile } from "@tauri-apps/plugin-fs"; + import { log } from "$lib/utils/log"; let { onStash, currentContextId, @@ -331,7 +332,7 @@ addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } } catch (resizeErr) { - console.warn( + log.warn( "Failed to resize image, falling back to original", resizeErr, ); @@ -497,7 +498,7 @@ left: textareaRef.offsetLeft + coords.left - textareaRef.scrollLeft, }; } catch (e) { - console.error("Failed to calculate caret coordinates", e); + log.error("Failed to calculate caret coordinates", e); } } @@ -569,7 +570,7 @@ }); } } catch (e) { - console.warn("Failed to resize pasted image", e); + log.warn("Failed to resize pasted image", e); } } @@ -700,7 +701,7 @@ try { await adapter.deleteAsset(asset.id, asset.filePath); } catch (err) { - console.error("Failed to delete removed asset:", err); + log.error("Failed to delete removed asset:", err); } } @@ -708,7 +709,7 @@ await onSave(content, files); } else { if (!currentContextId) { - console.error("Context ID required for new stash"); + log.error("Context ID required for new stash"); return; } const stash: StashItem = { @@ -738,7 +739,7 @@ removedAssets = []; attachmentErrors = []; } catch (e) { - console.error(e); + log.error(e); } finally { isSaving = false; } @@ -753,7 +754,7 @@ try { await adapter.deleteAsset(asset.id, asset.filePath); } catch (err) { - console.error("Failed to delete added asset on cancel:", err); + log.error("Failed to delete added asset on cancel:", err); } } @@ -791,7 +792,7 @@ try { await adapter.deleteAsset(asset.id, asset.filePath); } catch (err) { - console.error("Failed to delete asset on clear:", err); + log.error("Failed to delete asset on clear:", err); } } @@ -938,7 +939,7 @@ }, 0); }) .catch((err) => { - console.error("Failed to read clipboard text:", err); + log.error("Failed to read clipboard text:", err); }); return; } @@ -1043,7 +1044,7 @@ try { hoverPreviewData = await adapter.readFileForPreview(filePath); } catch (err) { - console.error("Failed to load hover preview:", err); + log.error("Failed to load hover preview:", err); hoverPreviewData = null; } finally { isLoadingHoverPreview = false; @@ -1173,7 +1174,7 @@ } } } catch (err) { - console.error("Failed to open file picker", err); + log.error("Failed to open file picker", err); } } diff --git a/src/lib/components/ExportDialog.svelte b/src/lib/components/ExportDialog.svelte index 5469344..7980366 100644 --- a/src/lib/components/ExportDialog.svelte +++ b/src/lib/components/ExportDialog.svelte @@ -18,6 +18,7 @@ import { formatBytes } from "$lib/utils/format"; import { Download, FileArchive, Square, CheckSquare } from "lucide-svelte"; + import { log } from "$lib/utils/log"; let { open = $bindable(false), context, @@ -229,7 +230,7 @@ } handleClose(); } catch (e) { - console.error("Export failed:", e); + log.error("Export failed:", e); errorMessage = errorText(e, $_("contexts.exportDialog.failed")); } finally { isExporting = false; diff --git a/src/lib/components/FilePreviewModal.svelte b/src/lib/components/FilePreviewModal.svelte index 1aa8280..8106a14 100644 --- a/src/lib/components/FilePreviewModal.svelte +++ b/src/lib/components/FilePreviewModal.svelte @@ -40,6 +40,7 @@ import { tooltip } from "$lib/actions/tooltip"; import { externalLinks } from "$lib/actions/externalLinks"; + import { log } from "$lib/utils/log"; let { open = $bindable(false), files = [], @@ -76,7 +77,7 @@ try { previewData = await adapter.readFileForPreview(path); } catch (err) { - console.error("Failed to load file preview:", err); + log.error("Failed to load file preview:", err); previewData = null; } finally { loading = false; @@ -176,7 +177,7 @@ if (s.videoVolume !== undefined) volume = s.videoVolume; if (s.videoMuted !== undefined) muted = s.videoMuted; } catch (e) { - console.error("Failed to load volume setting", e); + log.error("Failed to load volume setting", e); } } @@ -189,7 +190,7 @@ s.videoMuted = muted; await adapter.saveSettings(s); } catch (e) { - console.error("Failed to save volume setting", e); + log.error("Failed to save volume setting", e); } }, 1000); } @@ -259,7 +260,7 @@ try { await invoke("show_in_folder", { path: filePath }); } catch (err) { - console.error("Failed to open file location", err); + log.error("Failed to open file location", err); } } @@ -276,7 +277,7 @@ copied = false; }, 2000); } catch (err) { - console.error("Failed to copy path", err); + log.error("Failed to copy path", err); } } diff --git a/src/lib/components/FilePreviewTooltip.svelte b/src/lib/components/FilePreviewTooltip.svelte index f063875..f979153 100644 --- a/src/lib/components/FilePreviewTooltip.svelte +++ b/src/lib/components/FilePreviewTooltip.svelte @@ -33,6 +33,7 @@ import { getAttachmentKind } from "$lib/utils/files"; import { errorText } from "$lib/errors"; + import { log } from "$lib/utils/log"; let { filePath, fileName, @@ -97,7 +98,7 @@ const path = await attachmentSync.request(attachmentId, filePath); onclick?.(path); } catch (e) { - console.error("Failed to download attachment:", e); + log.error("Failed to download attachment:", e); } finally { isAwaitingOpen = false; } @@ -128,7 +129,7 @@ try { previewData = await adapter.readFileForPreview(resolvedPath); } catch (e) { - console.error("Failed to load preview:", e); + log.error("Failed to load preview:", e); error = $_( errorText(e, "common.unknownError"), ); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 79c05e8..4314462 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -37,6 +37,7 @@ import logoTypo from "../../../assets/stashpad/Typo.svg"; import { tooltip } from "$lib/actions/tooltip"; + import { log } from "$lib/utils/log"; let contextInfo = $state({ windowTitle: $_("header.checking"), processName: "", @@ -161,7 +162,7 @@ try { contextInfo = await adapter.getPreviousAppInfo(); } catch (e) { - console.error(e); + log.error(e); } }, 1000); return () => clearInterval(interval); diff --git a/src/lib/components/ImportDialog.svelte b/src/lib/components/ImportDialog.svelte index 0efd1be..27e488e 100644 --- a/src/lib/components/ImportDialog.svelte +++ b/src/lib/components/ImportDialog.svelte @@ -32,6 +32,7 @@ } from "lucide-svelte"; import { tooltip } from "$lib/actions/tooltip"; + import { log } from "$lib/utils/log"; let { open = $bindable(false), context, @@ -270,7 +271,7 @@ step = "preview"; } catch (e) { - console.error("Failed to parse file:", e); + log.error("Failed to parse file:", e); errorMessage = errorText(e, $_("contexts.importDialog.readFailed")); } finally { isParsing = false; @@ -385,7 +386,7 @@ onImportComplete(); handleClose(); } catch (e) { - console.error("Import failed:", e); + log.error("Import failed:", e); errorMessage = errorText(e, $_("contexts.importDialog.importFailed")); } finally { isImporting = false; diff --git a/src/lib/components/Queue.svelte b/src/lib/components/Queue.svelte index ab9284c..be52f37 100644 --- a/src/lib/components/Queue.svelte +++ b/src/lib/components/Queue.svelte @@ -66,6 +66,7 @@ type ResolvedRef, } from "$lib/utils/stash-refs"; import { reportError } from "$lib/utils/error-reporting"; + import { log } from "$lib/utils/log"; let { transferMode, @@ -645,13 +646,10 @@ } function handleDndConsider(e: CustomEvent) { - console.log( + // Ids only: this lands in the dev log file, and stash text does not belong there. + log.debug( "CONSIDER - items received:", - e.detail.items.map((i) => ({ - id: i.id, - isShadow: i.isDndShadowItem, - content: i.content?.substring(0, 20), - })), + e.detail.items.map((i) => ({ id: i.id, isShadow: i.isDndShadowItem })), ); // MUST keep shadows in array - library needs them to track dragged item @@ -663,13 +661,10 @@ } function handleDndFinalize(e: CustomEvent) { - console.log( + // Ids only: this lands in the dev log file, and stash text does not belong there. + log.debug( "FINALIZE - items received:", - e.detail.items.map((i) => ({ - id: i.id, - isShadow: i.isDndShadowItem, - content: i.content?.substring(0, 30), - })), + e.detail.items.map((i) => ({ id: i.id, isShadow: i.isDndShadowItem })), ); activeStashes = e.detail.items; diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index c550f58..70520ee 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -67,6 +67,7 @@ import { createSyncDisplay } from "$lib/utils/sync-display.svelte"; import { errorText } from "$lib/errors"; + import { log } from "$lib/utils/log"; let { settings = $bindable(), syncStatus, @@ -130,7 +131,7 @@ try { await adapter.saveSettings(settings); } catch (e) { - console.error("Failed to save settings", e); + log.error("Failed to save settings", e); } } @@ -207,7 +208,7 @@ clearTimeout(saveTimer); saveTimer = null; void adapter.saveSettings(settings).catch((e) => { - console.error("Failed to save settings", e); + log.error("Failed to save settings", e); }); } }); @@ -268,7 +269,7 @@ /** Opens the cloud sync waitlist on the website. */ function openWaitlist() { openUrl(waitlistUrl(settings.cloudConfig?.endpoint)).catch((err) => { - console.error("Failed to open the waitlist:", err); + log.error("Failed to open the waitlist:", err); }); } @@ -316,7 +317,7 @@ cloudUsage = await adapter.fetchCloudUsage(); } catch (e) { // Usage is informational; failing to fetch it must not disturb the panel. - console.warn("[Settings] Could not fetch cloud usage:", e); + log.warn("[Settings] Could not fetch cloud usage:", e); } } @@ -332,7 +333,7 @@ hasScreenRecordingPermission = await adapter.checkScreenRecordingPermission(); } catch (e) { - console.error("Failed to check screen recording permission:", e); + log.error("Failed to check screen recording permission:", e); } } @@ -344,7 +345,7 @@ save(); } } catch (e) { - console.error("Failed to get autostart status:", e); + log.error("Failed to get autostart status:", e); } }); @@ -355,7 +356,7 @@ try { await adapter.openMacosScreenRecordingSettings(); } catch (e) { - console.error("Failed to open screen recording settings:", e); + log.error("Failed to open screen recording settings:", e); } } @@ -398,7 +399,7 @@ save(); } } catch (e) { - console.error("Failed to check Apple Intelligence availability:", e); + log.error("Failed to check Apple Intelligence availability:", e); } }); @@ -472,7 +473,7 @@ }; save(); } catch (e) { - console.error("Failed to fetch subscription", e); + log.error("Failed to fetch subscription", e); } } @@ -510,7 +511,7 @@ : `${endpoint}/account/home`; openUrl(accountUrl).catch((err) => { - console.error("Failed to open account portal:", err); + log.error("Failed to open account portal:", err); }); } @@ -1249,7 +1250,7 @@ await adapter.setAutostart(settings.autostart ?? false); save(); } catch (e) { - console.error("Failed to update autostart:", e); + log.error("Failed to update autostart:", e); // Revert the toggle if it failed settings.autostart = !settings.autostart; } diff --git a/src/lib/components/StashCard.svelte b/src/lib/components/StashCard.svelte index 2eb2fd0..31310a8 100644 --- a/src/lib/components/StashCard.svelte +++ b/src/lib/components/StashCard.svelte @@ -83,6 +83,7 @@ import { tooltip } from "$lib/actions/tooltip"; import { externalLinks } from "$lib/actions/externalLinks"; + import { log } from "$lib/utils/log"; let { item, mode, @@ -337,7 +338,7 @@ await adapter.writeReferenceFile(item.contextId, item.id, ref.id, ref.full), ); } catch (err) { - console.error("Failed to write referenced stash to a file", err); + log.error("Failed to write referenced stash to a file", err); } } @@ -500,14 +501,14 @@ attachment.stashId = item.id; newAttachments.push(attachment); } catch (err) { - console.error("Failed to save asset from path", err); + log.error("Failed to save asset from path", err); } } // Update the stash with the new files onUpdateContent(item.content, newAttachments); } } catch (err) { - console.error("Failed to open file picker", err); + log.error("Failed to open file picker", err); } } diff --git a/src/lib/services/ai-service.svelte.ts b/src/lib/services/ai-service.svelte.ts index 0898679..33f1781 100644 --- a/src/lib/services/ai-service.svelte.ts +++ b/src/lib/services/ai-service.svelte.ts @@ -17,6 +17,7 @@ import { isAppleIntelligencePreset } from '$lib/utils/ai-presets'; import { listen } from '@tauri-apps/api/event'; import { DEFAULT_SYSTEM_PROMPT } from '$lib/constants/ai-prompts'; +import { log } from '$lib/utils/log'; const adapter = new DesktopStorageAdapter(); /** @@ -92,7 +93,7 @@ export class AIService { window.dispatchEvent(new CustomEvent('stashpad:prompt-reloaded')); } } catch (e) { - console.error('Failed to refresh system prompt:', e); + log.error('Failed to refresh system prompt:', e); } } @@ -105,7 +106,7 @@ export class AIService { await this.refreshPrompt(true); await adapter.openSystemPromptFile(); } catch (e) { - console.error('Failed to create prompt file:', e); + log.error('Failed to create prompt file:', e); } } diff --git a/src/lib/services/cloud-sync.ts b/src/lib/services/cloud-sync.ts index 14675b5..a0f1672 100644 --- a/src/lib/services/cloud-sync.ts +++ b/src/lib/services/cloud-sync.ts @@ -32,6 +32,7 @@ import { attachmentSync } from '../stores/attachment-sync.svelte'; import { errorText } from "$lib/errors"; import { fromEpochMs, nowIso, nowMs, toEpochMs } from "$lib/utils/time"; +import { log } from '$lib/utils/log'; // Fallback polling interval, used when the WebSocket is unavailable. const FALLBACK_SYNC_INTERVAL_MS = 15 * 60 * 1000; const DEBOUNCE_DELAY_MS = 2000; @@ -292,7 +293,7 @@ export async function getOrCreateDeviceId(adapter: IStorageService): Promise 0) { - console.info( + log.info( `[CloudSync] Re-encrypted ${conversion.converted} of ${conversion.remaining} older attachment(s)` ); } } catch (e) { - console.warn('[CloudSync] Could not re-encrypt older attachments:', e); + log.warn('[CloudSync] Could not re-encrypt older attachments:', e); } // Update last sync timestamp @@ -782,21 +783,21 @@ export class CloudSyncService { appliedRemoteChanges ); } - console.log(`[CloudSync] Synced ${stashCount} stashes, ${contextCount} contexts`); + log.info(`[CloudSync] Synced ${stashCount} stashes, ${contextCount} contexts`); // Confirming an upload publishes the file server-side but emits no WebSocket // notification of its own, so other devices would not hear about it until // their next fallback poll. One more pass makes the stash endpoint broadcast. // This terminates: the second pass uploads nothing, so it does not re-arm. if (uploadedAttachments) { - console.log('[CloudSync] Attachments uploaded - notifying other devices'); + log.info('[CloudSync] Attachments uploaded - notifying other devices'); this.triggerSync(); } return true; } catch (error) { const message = errorText(error); - console.error('[CloudSync] Sync failed:', message); + log.error('[CloudSync] Sync failed:', message); this.setStatus(this.isAuthError(message) ? 'auth-error' : 'error', message); return false; } finally { @@ -833,7 +834,7 @@ export class CloudSyncService { } } catch (e) { // Leaving the flags set only costs a redundant push next cycle. - console.warn(`[CloudSync] Could not clear pending flags for ${kind}:`, e); + log.warn(`[CloudSync] Could not clear pending flags for ${kind}:`, e); } } @@ -847,7 +848,7 @@ export class CloudSyncService { */ private reportRejected(kind: string, rejected?: RejectedRecord[]): void { if (!rejected?.length) return; - console.warn( + log.warn( `[CloudSync] Server rejected ${rejected.length} ${kind}:`, rejected.map(r => `${r.id}: ${r.reason}`).join('; ') ); @@ -866,7 +867,7 @@ export class CloudSyncService { } catch (error) { const msg = errorText(error); if (this.isAuthError(msg)) { - console.warn('[CloudSync] Stash sync rejected the credentials:', msg); + log.warn('[CloudSync] Stash sync rejected the credentials:', msg); this.authRejected = true; this.setStatus('auth-error', 'Authentication expired. Please log in again.'); return null; @@ -888,7 +889,7 @@ export class CloudSyncService { } catch (error) { const msg = errorText(error); if (this.isAuthError(msg)) { - console.warn('[CloudSync] Context sync rejected the credentials:', msg); + log.warn('[CloudSync] Context sync rejected the credentials:', msg); this.authRejected = true; this.setStatus('auth-error', 'Authentication expired. Please log in again.'); return null; @@ -1072,13 +1073,13 @@ export class CloudSyncService { this.wsUnlisten = await listen<{ type: string, source_device: string, timestamp: string }>('cloud:sync-notification', (event) => { // Do not sync if the notification came from our own device (loop prevention) if (event.payload.source_device !== this.deviceId) { - console.debug('[CloudSyncService] Received sync notification from', event.payload.source_device, '- scheduling sync'); + log.debug('[CloudSyncService] Received sync notification from', event.payload.source_device, '- scheduling sync'); this.scheduleRemoteSync(); } }); } } catch (error) { - console.error('[CloudSyncService] Failed to connect WebSocket:', error); + log.error('[CloudSyncService] Failed to connect WebSocket:', error); } } @@ -1096,7 +1097,7 @@ export class CloudSyncService { try { await this.adapter.disconnectWebSocket(); } catch (error) { - console.error('[CloudSyncService] Failed to disconnect WebSocket:', error); + log.error('[CloudSyncService] Failed to disconnect WebSocket:', error); } } @@ -1179,7 +1180,7 @@ export class CloudSyncService { // A failed transfer cost the same bandwidth as a successful one. attempted++; const msg = errorText(e); - console.warn(`[CloudSync] Attachment upload failed for ${att.id}:`, msg); + log.warn(`[CloudSync] Attachment upload failed for ${att.id}:`, msg); const attempts = (this.attachmentFailures.get(att.id) ?? 0) + 1; this.attachmentFailures.set(att.id, attempts); @@ -1196,7 +1197,7 @@ export class CloudSyncService { // Say so rather than looking like everything was covered. if (deferred > 0) { - console.log( + log.info( `[CloudSync] ${deferred} attachment(s) deferred to the next cycle (per-cycle limit reached)` ); // More work is waiting and nothing will announce it, so come back for it. @@ -1227,7 +1228,7 @@ export class CloudSyncService { }, }; - console.error( + log.error( `[CloudSync] ${failures.length} attachment upload(s) failed. First error: ${first.message}` ); } diff --git a/src/lib/services/desktop-adapter.ts b/src/lib/services/desktop-adapter.ts index 5287120..986ea7c 100644 --- a/src/lib/services/desktop-adapter.ts +++ b/src/lib/services/desktop-adapter.ts @@ -12,8 +12,10 @@ // See the GNU Affero General Public License for more details. import { invoke } from '@tauri-apps/api/core'; +import type { LogLevel } from '../utils/log'; import type { IStorageService, StashItem, AppContext, Settings, FilePreviewData, Context, Attachment, CloudConfig, ExportSummary, ImportPreview, CloudUsage, StashPosition, LocalKeyStatus, E2eeStatus, E2eeEnableResult, E2eeConversionProgress, CreatedAccessKey, AttachmentConversion } from '../types'; +import { log } from '$lib/utils/log'; /** Called after any local write so cloud sync can be scheduled. */ type MutationListener = () => void; @@ -38,7 +40,7 @@ function notifyMutation(): void { try { mutationListener?.(); } catch (e) { - console.warn('[DesktopAdapter] Mutation listener failed:', e); + log.warn('[DesktopAdapter] Mutation listener failed:', e); } } @@ -256,13 +258,13 @@ export class DesktopStorageAdapter implements IStorageService { } /** - * Forward a frontend error to the Rust logger. + * Forward a line from the frontend logger to the Rust logger. * - * The webview console is discarded in a release build, so without this an - * uncaught render error leaves no trace anywhere on disk. + * The webview console is discarded in a release build, so without this nothing the + * frontend logs leaves a trace on disk. */ - async logFrontendError(message: string): Promise { - return await invoke('log_frontend_error', { message }); + async logFrontend(level: LogLevel, message: string): Promise { + return await invoke('log_frontend', { level, message }); } async exchangeLinkCodeApi(token: string, deviceId?: string): Promise { @@ -315,7 +317,7 @@ export class DesktopStorageAdapter implements IStorageService { try { return await invoke('check_apple_intelligence_available'); } catch (e) { - console.error('Failed to check Apple Intelligence availability:', e); + log.error('Failed to check Apple Intelligence availability:', e); return false; } } diff --git a/src/lib/stores/attachment-sync.svelte.ts b/src/lib/stores/attachment-sync.svelte.ts index 6369c8f..f879a6f 100644 --- a/src/lib/stores/attachment-sync.svelte.ts +++ b/src/lib/stores/attachment-sync.svelte.ts @@ -27,6 +27,7 @@ import type { IStorageService } from '../types'; import { nowMs } from "$lib/utils/time"; +import { log } from '$lib/utils/log'; /** How many downloads run at once. Small, so a prioritised item starts promptly. */ const MAX_CONCURRENT = 2; @@ -191,7 +192,7 @@ export class AttachmentSyncQueue { this.waiters.get(id)?.forEach(w => w.resolve(path)); this.waiters.delete(id); } catch (e) { - console.warn(`[AttachmentSync] Download failed for ${id}:`, e); + log.warn(`[AttachmentSync] Download failed for ${id}:`, e); this.fail(id, e); } finally { this.active.delete(id); diff --git a/src/lib/stores/updater.svelte.ts b/src/lib/stores/updater.svelte.ts index c52070c..c4d86a3 100644 --- a/src/lib/stores/updater.svelte.ts +++ b/src/lib/stores/updater.svelte.ts @@ -28,6 +28,7 @@ import { nowMs } from "$lib/utils/time"; import { installKindFor, type InstallKind } from '../utils/installation'; import { errorText } from "$lib/errors"; +import { log } from '$lib/utils/log'; /** How long a successful check is good for. */ const CHECK_INTERVAL_MS = 48 * 60 * 60 * 1000; @@ -284,7 +285,7 @@ export class UpdateChecker { this.lastResult = 'error'; this.lastError = errorText(e); this.nextAttemptAt = deps.now() + RETRY_AFTER_ERROR_MS; - console.error('Failed to check for updates:', e); + log.error('Failed to check for updates:', e); } finally { this.status = 'idle'; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 78722e0..e8dc402 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -11,6 +11,8 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. +import type { LogLevel } from './utils/log'; + export interface Attachment { id: string; stashId: string; @@ -238,8 +240,8 @@ export interface IStorageService { deleteContext(id: string): Promise; setAutostart(enabled: boolean): Promise; getAutostartEnabled(): Promise; - /** Forward a frontend error to the backend logger. */ - logFrontendError(message: string): Promise; + /** Forward a line from the frontend logger to the backend's log file. */ + logFrontend(level: LogLevel, message: string): Promise; exchangeLinkCodeApi(token: string, deviceId?: string): Promise; /** Fetch account info from cloud and update local subscription status */ fetchCloudAccount(): Promise; diff --git a/src/lib/utils/__tests__/error-reporting.test.ts b/src/lib/utils/__tests__/error-reporting.test.ts index 8e0165f..3a33bc4 100644 --- a/src/lib/utils/__tests__/error-reporting.test.ts +++ b/src/lib/utils/__tests__/error-reporting.test.ts @@ -12,26 +12,26 @@ // See the GNU Affero General Public License for more details. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - installGlobalErrorReporter, - reportError, - setErrorForwarder, -} from '../error-reporting'; +import { installGlobalErrorReporter, reportError } from '../error-reporting'; +import { setLogForwarder } from '../log'; describe('error reporting', () => { let forwarded: string[]; + let levels: string[]; let consoleError: ReturnType; beforeEach(() => { forwarded = []; - setErrorForwarder((message) => { + levels = []; + setLogForwarder((level, message) => { + levels.push(level); forwarded.push(message); }); consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { - setErrorForwarder(null); + setLogForwarder(null); consoleError.mockRestore(); }); @@ -39,6 +39,7 @@ describe('error reporting', () => { reportError('render', new ReferenceError('bg is not defined')); expect(forwarded).toHaveLength(1); + expect(levels).toEqual(['error']); expect(forwarded[0]).toContain('[render]'); expect(forwarded[0]).toContain('bg is not defined'); }); @@ -60,7 +61,7 @@ describe('error reporting', () => { it('survives a forwarder that throws', () => { // The reporter runs *from* error handlers. If it could throw, it would replace // the original error with a less useful one. - setErrorForwarder(() => { + setLogForwarder(() => { throw new Error('backend unreachable'); }); @@ -69,7 +70,7 @@ describe('error reporting', () => { }); it('survives a forwarder that rejects', () => { - setErrorForwarder(() => Promise.reject(new Error('ipc down'))); + setLogForwarder(() => Promise.reject(new Error('ipc down'))); expect(() => reportError('render', new Error('original'))).not.toThrow(); }); diff --git a/src/lib/utils/__tests__/log.test.ts b/src/lib/utils/__tests__/log.test.ts new file mode 100644 index 0000000..d597b0b --- /dev/null +++ b/src/lib/utils/__tests__/log.test.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { log, setLogForwarder, type LogLevel } from '../log'; + +describe('log', () => { + let forwarded: [LogLevel, string][]; + + beforeEach(() => { + forwarded = []; + setLogForwarder((level, message) => { + forwarded.push([level, message]); + }); + for (const level of ['error', 'warn', 'info', 'debug'] as const) { + vi.spyOn(console, level).mockImplementation(() => {}); + } + }); + + afterEach(() => { + setLogForwarder(null); + vi.restoreAllMocks(); + }); + + it('writes to the console and forwards every level with its own level', () => { + log.error('e'); + log.warn('w'); + log.info('i'); + log.debug('d'); + + expect(console.error).toHaveBeenCalledWith('e'); + expect(console.debug).toHaveBeenCalledWith('d'); + expect(forwarded).toEqual([ + ['error', 'e'], + ['warn', 'w'], + ['info', 'i'], + ['debug', 'd'], + ]); + }); + + it('joins its arguments into one line, keeping an error\'s stack and an object\'s fields', () => { + log.error('Failed to save:', new Error('disk full'), { code: 'io' }); + + const [, message] = forwarded[0]; + expect(message).toMatch(/^Failed to save: Error: disk full\n/); + expect(message).toContain('{"code":"io"}'); + }); + + it('never throws, whatever the forwarder does', () => { + setLogForwarder(() => { + throw new Error('backend unreachable'); + }); + expect(() => log.error('still fine')).not.toThrow(); + + setLogForwarder(() => Promise.reject(new Error('ipc down'))); + expect(() => log.warn('still fine')).not.toThrow(); + }); + + it('still writes to the console before a forwarder is set', () => { + setLogForwarder(null); + log.info('early'); + expect(console.info).toHaveBeenCalledWith('early'); + }); +}); diff --git a/src/lib/utils/error-reporting.ts b/src/lib/utils/error-reporting.ts index f22e367..26b93aa 100644 --- a/src/lib/utils/error-reporting.ts +++ b/src/lib/utils/error-reporting.ts @@ -17,12 +17,14 @@ * In a release build the webview console goes nowhere, so an uncaught render error * left no trace at all - which is why an unquoted class ternary in the cloud-usage * bar froze the settings page for two releases without a single report pointing at - * it. Everything here also forwards to the Rust logger so it lands in the app log. + * it. Everything here goes through `log.ts`, so it lands in the app log. * * Nothing in this module may throw: it runs *from* error handlers, and a failure * here would replace the original error with a less useful one. */ +import { describe, log } from './log'; + /** Where the error came from, so the log line is diagnosable on its own. */ export type ErrorSource = | 'render' @@ -30,54 +32,14 @@ export type ErrorSource = | 'unhandled-rejection' | 'attachment'; -type Reporter = (message: string) => void | Promise; - -let forward: Reporter | null = null; - -/** - * Give the reporter a way to reach the backend logger. - * - * Injected rather than imported so this module stays free of Tauri imports and - * remains usable from a future web adapter. - */ -export function setErrorForwarder(reporter: Reporter | null): void { - forward = reporter; -} - -/** Flatten anything throwable into one log line, stack included when there is one. */ -function describe(error: unknown): string { - if (error instanceof Error) { - return error.stack ? `${error.name}: ${error.message}\n${error.stack}` : `${error.name}: ${error.message}`; - } - if (typeof error === 'string') return error; - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - /** - * Log an error locally and forward it to the backend. Never throws. + * Log an error through the app logger, tagged with where it came from. Never throws. * * `context` says what was being attempted, for errors that are caught and handled - * a rejected `invoke` carries the backend's message but not which action sent it. */ export function reportError(source: ErrorSource, error: unknown, context?: string): void { - const line = context - ? `[${source}] ${context}: ${describe(error)}` - : `[${source}] ${describe(error)}`; - try { - console.error(line); - } catch { - // A console that throws is not worth recovering from. - } - try { - // Fire and forget: a failed report must not mask the error being reported. - void Promise.resolve(forward?.(line)).catch(() => {}); - } catch { - // Same reasoning - swallow. - } + log.error(context ? `[${source}] ${context}: ${describe(error)}` : `[${source}] ${describe(error)}`); } /** diff --git a/src/lib/utils/log.ts b/src/lib/utils/log.ts new file mode 100644 index 0000000..52fb042 --- /dev/null +++ b/src/lib/utils/log.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +/** + * The webview's logger. Use this instead of `console.*`, which is the only other thing in + * the app allowed to touch the console. + * + * In a release build the webview console goes nowhere, so a `console.error` was lost the + * moment it was written - nothing a user could send, nothing to read after the fact. Each + * call here still writes to the console, for the devtools in a dev build, and also hands + * the line to the backend logger, which puts it in the same bounded log file as the Rust + * side's records. The backend decides what to keep: debug lines only reach the file in a + * dev build. + * + * Nothing in this module may throw: it runs from catch blocks and error handlers, and a + * failure here would replace the error being logged with a less useful one. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +type Forwarder = (level: LogLevel, message: string) => void | Promise; + +let forward: Forwarder | null = null; + +/** + * Give the logger a way to reach the backend. + * + * Injected rather than imported so this module stays free of Tauri imports and remains + * usable from a future web adapter. + */ +export function setLogForwarder(forwarder: Forwarder | null): void { + forward = forwarder; +} + +/** Flatten anything loggable into text, stack included when there is one. */ +export function describe(value: unknown): string { + if (value instanceof Error) { + return value.stack + ? `${value.name}: ${value.message}\n${value.stack}` + : `${value.name}: ${value.message}`; + } + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function emit(level: LogLevel, args: unknown[]): void { + try { + console[level](...args); + } catch { + // A console that throws is not worth recovering from. + } + try { + const message = args.map(describe).join(" "); + // Fire and forget: a failed forward must not mask what is being logged. + void Promise.resolve(forward?.(level, message)).catch(() => {}); + } catch { + // Same reasoning - swallow. + } +} + +/** Log a line to the console and to the app's log file. Never throws. */ +export const log = { + error: (...args: unknown[]) => emit("error", args), + warn: (...args: unknown[]) => emit("warn", args), + info: (...args: unknown[]) => emit("info", args), + debug: (...args: unknown[]) => emit("debug", args), +}; diff --git a/src/main.ts b/src/main.ts index 7482f09..45bc304 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,10 +16,8 @@ import "./app.css"; import App from "./App.svelte"; import { setupI18n, type SupportedLocale } from "$lib/i18n"; import { DesktopStorageAdapter } from "$lib/services/desktop-adapter"; -import { - installGlobalErrorReporter, - setErrorForwarder, -} from "$lib/utils/error-reporting"; +import { installGlobalErrorReporter } from "$lib/utils/error-reporting"; +import { log, setLogForwarder } from "$lib/utils/log"; // Extend window interface for initial data passing declare global { @@ -45,7 +43,7 @@ async function loadInitialData(): Promise<{ settings: Settings | null; locale: " }; } catch (error) { // If settings can't be loaded (e.g., first run), use automatic detection - console.warn("Could not load initial settings, using defaults:", error); + log.warn("Could not load initial settings, using defaults:", error); return { settings: null, locale: "auto" }; } } @@ -78,7 +76,7 @@ async function initApp(): Promise { // rather than lost. Without this the app can die behind the splash screen with no // trace at all in a release build. const reportingAdapter = new DesktopStorageAdapter(); - setErrorForwarder((message) => reportingAdapter.logFrontendError(message)); + setLogForwarder((level, message) => reportingAdapter.logFrontend(level, message)); installGlobalErrorReporter(); // Load initial data (settings includes locale preference) From a9bb86a59d6f66848d03c6856beda12a31c2b26f Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 05:37:46 +0200 Subject: [PATCH 4/7] Register the show/hide shortcut, and say when another app holds it The global shortcut plugin had a handler but nothing ever registered a shortcut for it. The registration used to live in save_settings and was lost when that file was split up; before that it ran only on a save, so it never survived a restart. Pressing the shortcut set in Settings therefore did nothing on any platform, with no error anywhere. shortcuts.rs now registers settings.shortcuts["global_toggle"] in the setup hook and again whenever save_settings sees it change. A failure never fails the save: it is logged and kept for a global_shortcut_error command, and Settings shows it beside the field. The plugin passes errors on as text, so a shortcut another app already holds is recognised by rebuilding global-hotkey's own AlreadyRegistered message for it rather than copying its wording. Only Windows and X11 report such a clash; macOS registers non-exclusively and accepts it, so there is nothing to detect there. The picker wrote the =/+ key as "Plus", which the parser does not know, so such a shortcut could never register; it writes "Equal" now. Tests cover every key and modifier the picker can write. Checked in a running debug build, where the saved shortcut registered at startup. --- CHANGELOG.md | 6 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/lib.rs | 9 +- src-tauri/src/settings.rs | 21 ++- src-tauri/src/shortcuts.rs | 219 ++++++++++++++++++++++++ src/lib/components/Settings.svelte | 29 +++- src/lib/components/ShortcutInput.svelte | 4 +- src/lib/i18n/locales/de.json | 5 + src/lib/i18n/locales/en.json | 5 + src/lib/i18n/locales/zh.json | 5 + src/lib/services/desktop-adapter.ts | 4 + src/lib/types.ts | 5 + 13 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/shortcuts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da7e3b..3690c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ popover, not for the person who wrote the commit. `stashpad-dev.log`, so it never mixes into the file from the installed app ### Fixed +- **The shortcut to show and hide Stashpad works again.** The shortcut set under Settings › + Shortcuts was saved but never switched on, on macOS and Windows alike, so pressing it did + nothing. It now works from the moment Stashpad starts and changes as soon as you pick a new + one. If Windows reports that another app already uses the combination, Settings says so + beside the shortcut; macOS does not tell apps about such clashes. A shortcut using the `=` + key could never work either and has to be set again - **A file you drop lands where you dropped it.** On a Mac with a Retina display, every drop was placed as if it had landed up and to the left of where it really did. A file dropped into a stash you were editing could end up in the main editor as well, and a file dropped onto one diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 778e77f..ef8d7d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5569,6 +5569,7 @@ dependencies = [ "drag", "fm-rs", "futures-util", + "global-hotkey", "hkdf", "keyring", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0fef3b2..6e64ab4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,9 @@ dirs = "6.0.0" arboard = "3.6.1" drag = { git = "https://github.com/crabnebula-dev/drag-rs" } tauri-plugin-global-shortcut = "2.3.1" +# Only for its error type: the plugin flattens errors to strings, and telling "another app +# holds this shortcut" apart from other failures means rebuilding that exact message. +global-hotkey = "0.7" tauri-plugin-dialog = "2" window-vibrancy = "0.5" tauri-plugin-window-state = "2.4.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ca8f15..86c20cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ mod localkey; mod logging; mod models; mod settings; +mod shortcuts; mod stashes; mod state; mod sync; @@ -326,6 +327,7 @@ pub fn run() { let settings = settings_state_for_setup.lock_settings(); let visual_effects_enabled = settings.visual_effects_enabled; let theme = settings.theme.clone(); + let global_toggle = settings.shortcuts.get(shortcuts::GLOBAL_TOGGLE).cloned(); // The native menu is built here, before the webview reports a locale, so the // one label we own is picked from the saved setting instead. It only changes // on restart, which matches the rest of the menu. @@ -333,6 +335,10 @@ pub fn run() { let locale = settings.locale.clone(); drop(settings); // Release lock + // The shortcut that shows and hides the window. Registered here as well as on + // every change, because a saved shortcut has to work from the first launch. + shortcuts::apply_global_toggle(app.handle(), None, global_toggle.as_deref()); + if let Some(window) = app.get_webview_window("main") { // Clear only when translucency is actually on. A permanently transparent // window is what let the desktop show through wherever the webview had @@ -563,7 +569,8 @@ pub fn run() { sync::disconnect_websocket, utils::get_installation_source, logging::log_frontend, - browser_drop::read_dropped_image + browser_drop::read_dropped_image, + shortcuts::global_shortcut_error ]) .plugin(tauri_plugin_deep_link::init()) // There is no `.setup()` here on purpose. diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cc7938d..df11570 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -389,7 +389,7 @@ pub async fn save_settings( // One critical section, not five. Each `lock_settings()` is a blocking acquire on // an async worker, and this command runs on every keystroke in the settings panel; // taking the lock five times per call multiplied that contention for no reason. - let (old_theme, old_autostart) = { + let (old_theme, old_autostart, old_toggle) = { let current = state.lock_settings(); // Don't save empty api keys if we already have one @@ -412,7 +412,14 @@ pub async fn save_settings( } } - (current.theme.clone(), current.autostart) + ( + current.theme.clone(), + current.autostart, + current + .shortcuts + .get(crate::shortcuts::GLOBAL_TOGGLE) + .cloned(), + ) }; // Publish to the in-memory state first, then write to disk off-thread. Callers only @@ -424,6 +431,16 @@ pub async fn save_settings( } persist_settings_off_thread(settings.clone()).await; + // Only when it changed: this command runs on every keystroke in the settings panel, and + // re-registering an unchanged shortcut each time would be a needless round trip to the OS. + let new_toggle = settings + .shortcuts + .get(crate::shortcuts::GLOBAL_TOGGLE) + .cloned(); + if new_toggle != old_toggle { + crate::shortcuts::apply_global_toggle(&app, old_toggle.as_deref(), new_toggle.as_deref()); + } + // Follow the theme in the native window, not just in the page. if settings.theme != old_theme { if let Some(window) = app.get_webview_window("main") { diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs new file mode 100644 index 0000000..71b32ba --- /dev/null +++ b/src-tauri/src/shortcuts.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +//! The global shortcut that shows and hides the window. +//! +//! The plugin's handler (in `lib.rs`) does the toggling; this is what puts a key +//! combination behind it. The combination is `settings.shortcuts["global_toggle"]`, a string +//! the shortcut picker (`ShortcutInput.svelte`) writes in the plugin's own format, for +//! example `CommandOrControl+Shift+K`. +//! +//! It is registered at startup and again whenever the setting changes. Both used to be +//! missing: the registration lived in `save_settings` and went when that file was split up, +//! and before that it only ever ran on a save, so it did not survive a restart either. Nothing +//! failed - the plugin simply had a handler and no shortcut to call it for. + +use std::str::FromStr; +use std::sync::Mutex; + +use tauri::AppHandle; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; + +use crate::uierror::UiError; + +/// The key in `settings.shortcuts` that holds the show/hide shortcut. +pub const GLOBAL_TOGGLE: &str = "global_toggle"; + +/// Why the current show/hide shortcut is not registered, for Settings to show beside it. +/// +/// Kept rather than returned because registration also happens at startup, long before +/// anyone opens Settings, and because a failed shortcut must not fail the settings save. +static LAST_ERROR: Mutex> = Mutex::new(None); + +/// Make `new` the show/hide shortcut and drop `old`. +/// +/// Both are the raw setting, where an empty string means "none". A failure is logged and +/// remembered for [`global_shortcut_error`], never returned: it must not stop the rest of +/// the settings from saving. +pub fn apply_global_toggle(app: &AppHandle, old: Option<&str>, new: Option<&str>) { + let shortcuts = app.global_shortcut(); + + if let Some(old) = old.filter(|s| !s.is_empty()) { + if let Err(e) = shortcuts.unregister(old) { + // Not fatal: it may never have registered in the first place. + log::debug!("Could not unregister shortcut '{}': {}", old, e); + } + } + + let outcome = match new.filter(|s| !s.is_empty()) { + None => None, + Some(new) => register(app, new).err(), + }; + *crate::state::lock_or_recover(&LAST_ERROR) = outcome; +} + +fn register(app: &AppHandle, shortcut: &str) -> Result<(), UiError> { + let parsed = Shortcut::from_str(shortcut).map_err(|e| { + log::warn!("Global shortcut '{}' cannot be parsed: {}", shortcut, e); + UiError::new( + "shortcut.invalid", + "This shortcut can't be used. Choose a different one.", + ) + })?; + match app.global_shortcut().register(parsed) { + Ok(()) => { + log::info!("Registered global shortcut '{}'", shortcut); + Ok(()) + } + Err(e) => { + log::warn!("Could not register global shortcut '{}': {}", shortcut, e); + Err(classify(parsed, &e.to_string())) + } + } +} + +/// Tell "another app holds it" apart from every other failure. +/// +/// The plugin hands errors on as text, so the library's own message for this shortcut is +/// rebuilt and compared, rather than its wording being copied here. The OS only reports a +/// clash on Windows and X11; macOS accepts a combination another app already holds, so +/// there it can never be detected and nothing is shown. +fn classify(shortcut: Shortcut, message: &str) -> UiError { + if message == global_hotkey::Error::AlreadyRegistered(shortcut).to_string() { + UiError::new( + "shortcut.taken", + "Another app already uses this shortcut. Choose a different one.", + ) + } else { + UiError::with_values( + "shortcut.failed", + format!("This shortcut couldn't be set up: {}", message), + [("reason", message)], + ) + } +} + +/// Why the show/hide shortcut is not active, or nothing when it is (or none is set). +#[tauri::command] +pub fn global_shortcut_error() -> Option { + crate::state::lock_or_recover(&LAST_ERROR).clone() +} + +/// The parsed shortcut, or why it was refused. For the tests. +#[cfg(test)] +fn parses(shortcut: &str) -> Result { + Shortcut::from_str(shortcut).map_err(|e| format!("{}: {}", shortcut, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every key name `ShortcutInput.svelte` can write, taken from its `KEY_MAP` and + /// `CODE_MAP`. A name the plugin rejects does not error anywhere: the picker shows the + /// shortcut, saves it, and it simply never fires. + const KEYS: &[&str] = &[ + "A", + "K", + "Z", + "0", + "5", + "9", + "F1", + "F5", + "F12", + "Up", + "Down", + "Left", + "Right", + "Home", + "End", + "PageUp", + "PageDown", + "Backspace", + "Delete", + "Insert", + "Tab", + "Enter", + "Escape", + "Space", + "CapsLock", + "NumLock", + "ScrollLock", + "PrintScreen", + "Pause", + "NumpadAdd", + "NumpadSubtract", + "NumpadMultiply", + "NumpadDivide", + "NumpadDecimal", + "NumpadEnter", + "Numpad0", + "Numpad9", + "Minus", + "Equal", + ]; + + #[test] + fn every_key_the_picker_writes_is_understood() { + let rejected: Vec = KEYS + .iter() + .filter_map(|key| parses(&format!("CommandOrControl+{}", key)).err()) + .collect(); + assert!( + rejected.is_empty(), + "the plugin rejects keys the picker offers: {:#?}", + rejected + ); + } + + #[test] + fn every_modifier_the_picker_writes_is_understood() { + for modifiers in [ + "CommandOrControl", + "Control", + "Super", + "Alt", + "Shift", + "CommandOrControl+Shift", + "Control+Alt+Shift", + "Super+Alt", + ] { + parses(&format!("{}+K", modifiers)).expect(modifiers); + } + } + + #[test] + fn a_shortcut_held_by_another_app_is_named_as_such() { + let shortcut = parses("CommandOrControl+Shift+K").unwrap(); + let held = global_hotkey::Error::AlreadyRegistered(shortcut).to_string(); + assert_eq!(classify(shortcut, &held).code, "shortcut.taken"); + } + + #[test] + fn any_other_failure_carries_its_reason() { + let shortcut = parses("CommandOrControl+Shift+K").unwrap(); + let error = classify(shortcut, "RegisterEventHotKey failed for KeyK"); + assert_eq!(error.code, "shortcut.failed"); + assert_eq!( + error.values.unwrap().get("reason").map(String::as_str), + Some("RegisterEventHotKey failed for KeyK") + ); + } + + #[test] + fn a_nonsense_shortcut_is_refused_rather_than_accepted() { + assert!(parses("CommandOrControl+NotAKey").is_err()); + assert!(parses("").is_err()); + } +} diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 70520ee..9e7657c 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -51,6 +51,7 @@ ExternalLink, FileText, RefreshCw, + TriangleAlert, } from "lucide-svelte"; import TagBadge from "./TagBadge.svelte"; import SettingsButton from "./SettingsButton.svelte"; @@ -122,6 +123,19 @@ const SAVE_DEBOUNCE_MS = 400; let saveTimer: ReturnType | null = null; + // Why the show/hide shortcut is not active. Only what the OS reports is shown: on macOS a + // combination another app holds registers without complaint, so there is nothing to say. + let globalShortcutError = $state(null); + + async function refreshGlobalShortcutError() { + try { + const error = await adapter.getGlobalShortcutError(); + globalShortcutError = error ? errorText(error) : null; + } catch (e) { + log.warn("Could not read the global shortcut status", e); + } + } + async function save() { // A pending debounced save is now redundant - this call carries the same state. if (saveTimer) { @@ -323,6 +337,7 @@ onMount(loadCloudUsage); onMount(refreshDeviceKey); + onMount(refreshGlobalShortcutError); onMount(async () => { isWin10 = await adapter.isWindows10(); @@ -1311,13 +1326,23 @@ { + onchange={async (shortcut) => { if (!settings.shortcuts) settings.shortcuts = {}; settings.shortcuts["global_toggle"] = shortcut; - save(); + await save(); + await refreshGlobalShortcutError(); }} /> + {#if globalShortcutError} +

+ + {globalShortcutError} +

+ {/if} diff --git a/src/lib/components/ShortcutInput.svelte b/src/lib/components/ShortcutInput.svelte index cfa9e88..23fffb2 100644 --- a/src/lib/components/ShortcutInput.svelte +++ b/src/lib/components/ShortcutInput.svelte @@ -119,7 +119,7 @@ Numpad9: "Numpad9", // Regular minus/plus for distinction Minus: "Minus", - Equal: "Plus", + Equal: "Equal", }; /** @@ -158,7 +158,7 @@ Numpad8: "Num8", Numpad9: "Num9", Minus: "-", - Plus: "+", + Equal: "=", }; /** diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 9d80c01..4bbdd00 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -597,6 +597,11 @@ "drop": { "nothing_usable": "Der abgelegte Inhalt enthält nichts, was Stashpad anhängen kann. Kopiere das Bild stattdessen und füge es ein.", "download_failed": "Das abgelegte Bild konnte nicht heruntergeladen werden: {reason}" + }, + "shortcut": { + "taken": "Eine andere App verwendet dieses Tastenkürzel bereits. Wähle ein anderes.", + "failed": "Dieses Tastenkürzel konnte nicht eingerichtet werden: {reason}", + "invalid": "Dieses Tastenkürzel kann nicht verwendet werden. Wähle ein anderes." } } } diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index d32acaf..11e1997 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -597,6 +597,11 @@ "drop": { "nothing_usable": "This drop contained nothing Stashpad can attach. Try copying the image and pasting it instead.", "download_failed": "Couldn't download the dropped image: {reason}" + }, + "shortcut": { + "taken": "Another app already uses this shortcut. Choose a different one.", + "failed": "This shortcut couldn't be set up: {reason}", + "invalid": "This shortcut can't be used. Choose a different one." } } } diff --git a/src/lib/i18n/locales/zh.json b/src/lib/i18n/locales/zh.json index 2818a32..d45f05f 100644 --- a/src/lib/i18n/locales/zh.json +++ b/src/lib/i18n/locales/zh.json @@ -597,6 +597,11 @@ "drop": { "nothing_usable": "拖入的内容中没有可附加的项目。请改为复制图片后粘贴。", "download_failed": "无法下载拖入的图片:{reason}" + }, + "shortcut": { + "taken": "其他应用已在使用此快捷键。请选择其他快捷键。", + "failed": "无法设置此快捷键:{reason}", + "invalid": "无法使用此快捷键。请选择其他快捷键。" } } } diff --git a/src/lib/services/desktop-adapter.ts b/src/lib/services/desktop-adapter.ts index 986ea7c..3a774b0 100644 --- a/src/lib/services/desktop-adapter.ts +++ b/src/lib/services/desktop-adapter.ts @@ -159,6 +159,10 @@ export class DesktopStorageAdapter implements IStorageService { return await invoke('read_dropped_image'); } + async getGlobalShortcutError(): Promise { + return await invoke('global_shortcut_error'); + } + /** * Import an asset from an external file path into the cache directory. * Files are organized hierarchically: cache/// diff --git a/src/lib/types.ts b/src/lib/types.ts index e8dc402..7ad7905 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -200,6 +200,11 @@ export interface IStorageService { * scratch file, and return its path for the same handling as a dropped file. */ readDroppedImage(): Promise; + /** + * Why the show/hide shortcut is not active, as a backend error for `errorText`, or null + * when it is registered or none is set. + */ + getGlobalShortcutError(): Promise; /** * Delete an attachment: its row, and its file once no other row references it. * @param id - The attachment id; empty for one never written to the database From d5158220a4828649a3aae608d2f1b26b5d785c65 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 10:09:52 +0200 Subject: [PATCH 5/7] Stop the Linux Clippy job failing on helpers only macOS uses scratch_dir, write_scratch and file_name in browser_drop.rs are called only by the macOS drop reader, so on Linux nothing used them and Clippy, which treats warnings as errors in CI, failed the Ubuntu backend job on three dead-code findings. Every commit since the browser-drop work failed it; macOS and Windows never saw it because the code is live there, and Clippy only runs on Ubuntu. The first two move into the macOS module that uses them. file_name stays outside it because its tests run on every platform, and is compiled only on macOS and under test. Clippy, rustfmt and the four browser_drop tests pass locally; the Linux build itself can only be confirmed by CI. --- src-tauri/src/browser_drop.rs | 45 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/browser_drop.rs b/src-tauri/src/browser_drop.rs index 6fe7679..c1b6835 100644 --- a/src-tauri/src/browser_drop.rs +++ b/src-tauri/src/browser_drop.rs @@ -30,16 +30,8 @@ //! The result is written to a scratch file and its path handed back, so the interface can //! treat it exactly like a file dropped from Finder: same resize, same save, same errors. -use std::path::PathBuf; - use crate::uierror::UiError; -/// Scratch folder for the image of the latest browser drop. Emptied on every drop, so at -/// most one stale file lingers - and it is under the app folder the fs scope allows. -fn scratch_dir() -> PathBuf { - crate::utils::get_app_dir().join("tmp").join("drop") -} - fn nothing_usable() -> UiError { UiError::new( "drop.nothing_usable", @@ -63,6 +55,9 @@ pub async fn read_dropped_image() -> Result { } /// A filename for the image, from the last segment of its URL when there is one. +/// +/// Only the macOS reader names files; the tests run it everywhere. +#[cfg(any(target_os = "macos", test))] fn file_name(url: Option<&str>, ext: &str) -> String { let stem = url .and_then(|u| reqwest::Url::parse(u).ok()) @@ -78,18 +73,6 @@ fn file_name(url: Option<&str>, ext: &str) -> String { format!("{}.{}", stem, ext) } -/// Write the image to the scratch folder, replacing whatever the last drop left there. -fn write_scratch(name: &str, bytes: &[u8]) -> Result { - let dir = scratch_dir(); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create {}: {}", dir.display(), e))?; - let path = dir.join(name); - std::fs::write(&path, bytes) - .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; - Ok(path.to_string_lossy().into_owned()) -} - #[cfg(target_os = "macos")] mod macos { use std::time::Duration; @@ -99,9 +82,29 @@ mod macos { }; use objc2_foundation::{NSData, NSDictionary, NSString}; - use super::{file_name, nothing_usable, write_scratch}; + use std::path::PathBuf; + + use super::{file_name, nothing_usable}; use crate::uierror::UiError; + /// Scratch folder for the image of the latest browser drop. Emptied on every drop, so at + /// most one stale file lingers - and it is under the app folder the fs scope allows. + fn scratch_dir() -> PathBuf { + crate::utils::get_app_dir().join("tmp").join("drop") + } + + /// Write the image to the scratch folder, replacing whatever the last drop left there. + fn write_scratch(name: &str, bytes: &[u8]) -> Result { + let dir = scratch_dir(); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create {}: {}", dir.display(), e))?; + let path = dir.join(name); + std::fs::write(&path, bytes) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + Ok(path.to_string_lossy().into_owned()) + } + /// Original image formats, as pasteboard type and file extension, best first. const ORIGINAL_TYPES: [(&str, &str); 4] = [ ("public.png", "png"), From 42c8829eba470fdc07adfaee70e15ccfb4d67371 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 10:13:03 +0200 Subject: [PATCH 6/7] Bring package-lock.json's own version up to the app's The lockfile still named the app 1.9.0 in its two version fields, three releases behind package.json. Nothing reads them at runtime, but a lockfile that disagrees with its package.json is noise in every later diff, and a local npm install kept rewriting them as an unrelated change. Only those two fields: the same install also dropped the libc markers npm uses to pick the right optional native packages on Linux, and those stay. --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29935a3..b316773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stashpad", - "version": "1.9.0", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stashpad", - "version": "1.9.0", + "version": "1.10.1", "license": "AGPL-3.0", "dependencies": { "@tailwindcss/typography": "^0.5.19", From 4ab4568f870d85f1e82b68fc8971436435607b66 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Mon, 5 Oct 2026 10:14:18 +0200 Subject: [PATCH 7/7] chore: bump version to v1.10.2 --- CHANGELOG.md | 14 ++++++++------ package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3690c2a..d3921c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ popover, not for the person who wrote the commit. ## [Unreleased] +## [1.10.2] - 2026-10-05 + ### Added - **You can drag images straight from your browser into a stash.** On macOS, an image dropped from Chrome, Safari or another browser used to be ignored without a word, because the @@ -31,6 +33,10 @@ popover, not for the person who wrote the commit. `stashpad-dev.log`, so it never mixes into the file from the installed app ### Fixed +- **Pasting an image into a stash works again.** Since 1.6.3, a pasted screenshot or copied + image was silently dropped: nothing appeared and no error was shown. Dropping a large image + failed the same way when "Resize images" was on, because the shrunk copy is saved the same + way a pasted image is. Text pastes and files dropped from Finder were not affected - **The shortcut to show and hide Stashpad works again.** The shortcut set under Settings › Shortcuts was saved but never switched on, on macOS and Windows alike, so pressing it did nothing. It now works from the moment Stashpad starts and changes as soon as you pick a new @@ -41,12 +47,8 @@ popover, not for the person who wrote the commit. was placed as if it had landed up and to the left of where it really did. A file dropped into a stash you were editing could end up in the main editor as well, and a file dropped onto one stash in the queue could be attached to a different one, or to none -- **Pasting an image into a stash works again.** Since 1.6.3, a pasted screenshot or copied - image was silently dropped: nothing appeared and no error was shown. Dropping a large image - failed the same way when "Resize images" was on, because the shrunk copy is saved the same - way a pasted image is. Text pastes and files dropped from Finder were not affected -- Failed attachment saves are now written to the app log, so a problem like this one leaves a - trace instead of disappearing +- Failed attachment saves are now written to the app log, so a problem like the paste failure + leaves a trace instead of disappearing ## [1.10.1] - 2026-10-01 diff --git a/package-lock.json b/package-lock.json index b316773..3af3aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stashpad", - "version": "1.10.1", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stashpad", - "version": "1.10.1", + "version": "1.10.2", "license": "AGPL-3.0", "dependencies": { "@tailwindcss/typography": "^0.5.19", diff --git a/package.json b/package.json index d6b627b..55419c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stashpad", - "version": "1.10.1", + "version": "1.10.2", "description": "The staging area for your AI context.", "author": { "name": "Nico Wiedemann", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ef8d7d2..d192aa5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5557,7 +5557,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stashpad" -version = "1.10.1" +version = "1.10.2" dependencies = [ "active-win-pos-rs", "aes-gcm", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e64ab4..4d19ca2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stashpad" -version = "1.10.1" +version = "1.10.2" description = "The staging area for your AI context." authors = ["Nico Wiedemann "] repository = "https://github.com/EarMaster/stashpad" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 235515f..d7a1e0a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "stashpad", - "version": "1.10.1", + "version": "1.10.2", "identifier": "org.stashpad", "build": { "frontendDist": "../dist",