diff --git a/CHANGELOG.md b/CHANGELOG.md index ce86602..d3921c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,45 @@ 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 + 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 + +### 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 +- **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 + 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 + stash in the queue could be attached to a different one, or to none +- 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 ### Changed diff --git a/package-lock.json b/package-lock.json index 29935a3..3af3aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stashpad", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stashpad", - "version": "1.9.0", + "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 77882e2..d192aa5 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" @@ -5934,7 +5557,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stashpad" -version = "1.10.1" +version = "1.10.2" dependencies = [ "active-win-pos-rs", "aes-gcm", @@ -5946,10 +5569,13 @@ dependencies = [ "drag", "fm-rs", "futures-util", + "global-hotkey", "hkdf", "keyring", "log", "mime_guess", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rand 0.8.8", "regex", "reqwest 0.12.28", @@ -5962,7 +5588,6 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-deep-link", - "tauri-plugin-devtools", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-global-shortcut", @@ -6096,12 +5721,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" @@ -6257,7 +5876,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.4.0", + "http", "http-range", "jni", "libc", @@ -6285,7 +5904,6 @@ dependencies = [ "tauri-utils", "thiserror 2.0.18", "tokio", - "tracing", "tray-icon", "url", "webkit2gtk", @@ -6408,33 +6026,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" @@ -6591,7 +6182,7 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures-util", - "http 1.4.0", + "http", "infer", "log", "minisign-verify", @@ -6638,7 +6229,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http 1.4.0", + "http", "jni", "objc2 0.6.4", "objc2-ui-kit", @@ -6661,7 +6252,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", @@ -6673,7 +6264,6 @@ dependencies = [ "tao", "tauri-runtime", "tauri-utils", - "tracing", "url", "webkit2gtk", "webview2-com", @@ -6695,7 +6285,7 @@ dependencies = [ "dunce", "glob", "html5ever 0.29.1", - "http 1.4.0", + "http", "infer", "json-patch", "kuchikiki", @@ -6823,15 +6413,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" @@ -6938,26 +6519,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", @@ -6984,17 +6555,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" @@ -7118,86 +6678,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" @@ -7207,30 +6687,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" @@ -7240,11 +6702,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", ] @@ -7267,7 +6729,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", @@ -7291,36 +6752,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]] @@ -7359,7 +6790,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", "native-tls", @@ -7549,12 +6980,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" @@ -8229,15 +7654,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" @@ -8682,7 +8098,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http 1.4.0", + "http", "javascriptcore-rs", "jni", "libc", @@ -8700,7 +8116,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 d8c84cf..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" @@ -34,7 +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" -tauri-plugin-devtools = "2.0.0" +# 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" @@ -68,7 +70,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"] } @@ -86,6 +88,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..c1b6835 --- /dev/null +++ b/src-tauri/src/browser_drop.rs @@ -0,0 +1,288 @@ +// 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 crate::uierror::UiError; + +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. +/// +/// 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()) + .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) +} + +#[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 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"), + ("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/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 8b709ab..86c20cb 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; @@ -34,8 +35,10 @@ mod e2ee_session; mod envelope; mod keychain; mod localkey; +mod logging; mod models; mod settings; +mod shortcuts; mod stashes; mod state; mod sync; @@ -143,9 +146,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(); @@ -253,9 +255,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(); @@ -263,10 +265,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. // @@ -308,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. @@ -315,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 @@ -412,31 +436,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()) @@ -569,7 +568,9 @@ 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, + shortcuts::global_shortcut_error ]) .plugin(tauri_plugin_deep_link::init()) // There is no `.setup()` here on purpose. @@ -585,7 +586,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); } @@ -630,7 +631,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..df11570 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; } @@ -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-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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6e0ed10..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", @@ -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/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/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/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 87b5a04..81495d8 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 { @@ -68,8 +69,11 @@ 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 { errorText } from "$lib/errors"; import { readFile } from "@tauri-apps/plugin-fs"; + import { log } from "$lib/utils/log"; let { onStash, currentContextId, @@ -172,6 +176,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); @@ -231,17 +251,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 @@ -301,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, ); @@ -324,7 +355,7 @@ addedAssets = [...addedAssets, { id: attachment.id, filePath: attachment.filePath }]; } } catch (err) { - console.error("Failed to save dropped asset", err); + noteAttachmentError(err, "Failed to save dropped asset", saveFailedMessage(path.split(/[\\/]/).pop() ?? path, err)); } } }); @@ -467,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); } } @@ -539,7 +570,7 @@ }); } } catch (e) { - console.warn("Failed to resize pasted image", e); + log.warn("Failed to resize pasted image", e); } } @@ -552,7 +583,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); + noteAttachmentError(err, "Failed to save pasted file", saveFailedMessage(file.name, err)); } } return; @@ -618,7 +649,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); + noteAttachmentError(err, "Failed to save text as attachment", saveFailedMessage(filename, err)); } } @@ -670,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); } } @@ -678,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 = { @@ -706,8 +737,9 @@ // Clear tracking arrays after successful save addedAssets = []; removedAssets = []; + attachmentErrors = []; } catch (e) { - console.error(e); + log.error(e); } finally { isSaving = false; } @@ -722,13 +754,14 @@ 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); } } // Clear tracking arrays addedAssets = []; removedAssets = []; + attachmentErrors = []; // Call the original onCancel callback onCancel?.(); @@ -759,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); } } @@ -769,6 +802,7 @@ addedAssets = []; removedAssets = []; clearConfirmDialogOpen = false; + attachmentErrors = []; isExpanded = false; } @@ -905,7 +939,7 @@ }, 0); }) .catch((err) => { - console.error("Failed to read clipboard text:", err); + log.error("Failed to read clipboard text:", err); }); return; } @@ -1010,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; @@ -1040,6 +1074,7 @@ /** * Opens a file picker dialog and adds selected files to the stash. */ + let rootEl = $state(null); let textareaRef = $state(null); function adjustTextareaHeight() { @@ -1134,12 +1169,12 @@ // 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); + noteAttachmentError(err, "Failed to save picked file", saveFailedMessage(path.split(/[\\/]/).pop() ?? path, err)); } } } } catch (err) { - console.error("Failed to open file picker", err); + log.error("Failed to open file picker", err); } } @@ -1156,6 +1191,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/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 47be5ed..be52f37 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, @@ -65,6 +65,8 @@ resolveRef, type ResolvedRef, } from "$lib/utils/stash-refs"; + import { reportError } from "$lib/utils/error-reporting"; + import { log } from "$lib/utils/log"; let { transferMode, @@ -644,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 @@ -662,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; @@ -711,7 +707,7 @@ "tauri://drag-enter", (event) => { setDragging(true); - const stashId = findStashAtPosition( + const stashId = findCardDropTarget( event.payload.position.x, event.payload.position.y, ); @@ -722,7 +718,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, ); @@ -739,7 +735,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, ); @@ -750,7 +746,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 { @@ -762,7 +767,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/components/Settings.svelte b/src/lib/components/Settings.svelte index c550f58..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"; @@ -67,6 +68,7 @@ import { createSyncDisplay } from "$lib/utils/sync-display.svelte"; import { errorText } from "$lib/errors"; + import { log } from "$lib/utils/log"; let { settings = $bindable(), syncStatus, @@ -121,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) { @@ -130,7 +145,7 @@ try { await adapter.saveSettings(settings); } catch (e) { - console.error("Failed to save settings", e); + log.error("Failed to save settings", e); } } @@ -207,7 +222,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 +283,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,12 +331,13 @@ 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); } } onMount(loadCloudUsage); onMount(refreshDeviceKey); + onMount(refreshGlobalShortcutError); onMount(async () => { isWin10 = await adapter.isWindows10(); @@ -332,7 +348,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 +360,7 @@ save(); } } catch (e) { - console.error("Failed to get autostart status:", e); + log.error("Failed to get autostart status:", e); } }); @@ -355,7 +371,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 +414,7 @@ save(); } } catch (e) { - console.error("Failed to check Apple Intelligence availability:", e); + log.error("Failed to check Apple Intelligence availability:", e); } }); @@ -472,7 +488,7 @@ }; save(); } catch (e) { - console.error("Failed to fetch subscription", e); + log.error("Failed to fetch subscription", e); } } @@ -510,7 +526,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 +1265,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; } @@ -1310,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/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/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 673b84d..4bbdd00 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,15 @@ "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}" + }, + "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 36bf3c5..11e1997 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,15 @@ "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}" + }, + "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 9758f07..d45f05f 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,15 @@ "passphrase_empty": "密码短语不能为空。", "no_passphrase": "这台电脑上未设置密码短语。", "locked": "请先输入你的密码短语。" + }, + "drop": { + "nothing_usable": "拖入的内容中没有可附加的项目。请改为复制图片后粘贴。", + "download_failed": "无法下载拖入的图片:{reason}" + }, + "shortcut": { + "taken": "其他应用已在使用此快捷键。请选择其他快捷键。", + "failed": "无法设置此快捷键:{reason}", + "invalid": "无法使用此快捷键。请选择其他快捷键。" } } } 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 4ee7eee..3a774b0 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); } } @@ -153,6 +155,14 @@ export class DesktopStorageAdapter implements IStorageService { await invoke('start_drag', { text, files }); } + async readDroppedImage(): Promise { + 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/// @@ -252,13 +262,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 { @@ -311,7 +321,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/__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/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/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/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/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..7ad7905 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; @@ -193,6 +195,16 @@ 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; + /** + * 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 @@ -233,8 +245,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 7d8a1b6..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'); }); @@ -50,10 +51,17 @@ 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. - setErrorForwarder(() => { + setLogForwarder(() => { throw new Error('backend unreachable'); }); @@ -62,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 10d5c7d..26b93aa 100644 --- a/src/lib/utils/error-reporting.ts +++ b/src/lib/utils/error-reporting.ts @@ -17,59 +17,29 @@ * 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' | 'uncaught' - | 'unhandled-rejection'; - -type Reporter = (message: string) => void | Promise; - -let forward: Reporter | null = null; + | 'unhandled-rejection' + | 'attachment'; /** - * Give the reporter a way to reach the backend logger. + * Log an error through the app logger, tagged with where it came from. Never throws. * - * Injected rather than imported so this module stays free of Tauri imports and - * remains usable from a future web adapter. + * `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 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. */ -export function reportError(source: ErrorSource, error: unknown): void { - const line = `[${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. - } +export function reportError(source: ErrorSource, error: unknown, context?: string): void { + 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)