From 967a7833ca141d5b6af616a64a93b56dfb0481a7 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 4 Jul 2026 00:26:26 +0900 Subject: [PATCH] feat(sentry): add crash reporting for Android, iOS, desktop, and web (#4914) Report crashes and unhandled errors from every layer to one Sentry project via a single build-time SENTRY_DSN (empty => disabled, so local and fork builds do not report): - JS/WebView + Rust panics via tauri-plugin-sentry (rustls transport; minidump handler desktop-only). - Android native (JVM/NDK/ANR) via sentry-android 8.47.0 with manifest auto-init (excludes the discontinued lifecycle-common-java8 transitive). - iOS native via sentry-cocoa started from a tracked SentrySupport/+load bootstrap that reads the DSN from a Rust readest_sentry_dsn() FFI (no generated-file edits). - SENTRY_DSN resolved at build time from the environment, then .env.local, then .env (build.rs bakes it via cargo:rustc-env; build.gradle.kts for Android). CI passes the SENTRY_DSN secret through the existing .env.local step. Crashes + errors only: traces sample rate 0, no session replay, no PII. Symbolication (source maps / ProGuard / dSYM upload) is a follow-up. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/nightly.yml | 1 + .github/workflows/release.yml | 1 + Cargo.lock | 453 +++++++++++++++++- apps/readest-app/src-tauri/Cargo.toml | 14 + apps/readest-app/src-tauri/build.rs | 57 ++- .../src-tauri/capabilities/default.json | 3 +- .../gen/android/app/build.gradle.kts | 23 + .../android/app/src/main/AndroidManifest.xml | 6 + .../gen/apple/SentrySupport/SentryBootstrap.m | 31 ++ .../src-tauri/gen/apple/project.yml | 7 + apps/readest-app/src-tauri/src/lib.rs | 30 ++ .../src-tauri/src/sentry_config.rs | 82 ++++ 12 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 apps/readest-app/src-tauri/gen/apple/SentrySupport/SentryBootstrap.m create mode 100644 apps/readest-app/src-tauri/src/sentry_config.rs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 09c6d123..d50de6e4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -142,6 +142,7 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local cp .env.local apps/readest-app/.env.local - name: install rclone diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0664447e..0bb5bade 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -229,6 +229,7 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local cp .env.local apps/readest-app/.env.local - name: build and upload Android apks diff --git a/Cargo.lock b/Cargo.lock index 5489bc4d..25a16c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "read-progress-stream", "reqwest 0.12.28", "semver", + "sentry", "serde", "serde_json", "tauri", @@ -57,6 +58,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-persisted-scope", "tauri-plugin-process", + "tauri-plugin-sentry", "tauri-plugin-sharekit", "tauri-plugin-shell", "tauri-plugin-sign-in-with-apple", @@ -75,6 +77,15 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -545,6 +556,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -749,7 +775,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -1031,6 +1057,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1341,6 +1373,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crash-context" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" +dependencies = [ + "cfg-if", + "libc", + "mach2", +] + +[[package]] +name = "crash-handler" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" +dependencies = [ + "cfg-if", + "crash-context", + "libc", + "mach2", + "parking_lot", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -1622,6 +1678,16 @@ dependencies = [ "keyring-core", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid 1.23.2", +] + [[package]] name = "default-net" version = "0.22.0" @@ -2731,6 +2797,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -2895,6 +2967,17 @@ dependencies = [ "system-deps 7.0.8", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3069,6 +3152,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -3984,6 +4078,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4127,6 +4230,75 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minidump-common" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" +dependencies = [ + "bitflags 2.11.1", + "debugid", + "num-derive", + "num-traits", + "range-map", + "scroll", + "smart-default", +] + +[[package]] +name = "minidump-writer" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "cfg-if", + "crash-context", + "goblin", + "libc", + "log", + "mach2", + "memmap2", + "memoffset", + "minidump-common", + "nix 0.28.0", + "procfs-core", + "scroll", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "minidumper" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1" +dependencies = [ + "cfg-if", + "crash-context", + "libc", + "log", + "minidump-writer", + "parking_lot", + "polling", + "scroll", + "thiserror 1.0.69", + "uds", +] + +[[package]] +name = "minidumper-child" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4f23f835dbe67e44ddf884d3802ff549ca5948bf60e9fd70e9a13c96324d1" +dependencies = [ + "crash-handler", + "minidumper", + "thiserror 1.0.69", + "uuid 1.23.2", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4352,6 +4524,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -4360,7 +4544,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -4487,6 +4671,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4857,6 +5052,15 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5387,6 +5591,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.9.0" @@ -5582,6 +5792,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.11.1", + "hex", +] + [[package]] name = "proptest" version = "1.11.0" @@ -5725,7 +5945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -5765,7 +5985,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -5944,6 +6164,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "range-map" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f" +dependencies = [ + "num-traits", +] + [[package]] name = "rapidhash" version = "4.4.1" @@ -6079,6 +6308,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -6270,6 +6500,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6333,6 +6569,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6515,6 +6752,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sdd" version = "3.0.10" @@ -6597,6 +6854,113 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sentry" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" +dependencies = [ + "httpdate", + "reqwest 0.12.28", + "rustls", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +dependencies = [ + "rand 0.9.4", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-panic" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-rust-minidump" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63964525bf74b16233dbcfb307e11485ebd8ff8f87f6ae212b07ca7937cd2db1" +dependencies = [ + "minidumper-child", + "sentry", + "thiserror 2.0.18", +] + +[[package]] +name = "sentry-tracing" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" +dependencies = [ + "bitflags 2.11.1", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +dependencies = [ + "debugid", + "hex", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.23.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -6989,6 +7353,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.6.4" @@ -7994,6 +8369,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-sentry" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295" +dependencies = [ + "base64 0.22.1", + "schemars 0.8.22", + "sentry", + "sentry-rust-minidump", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-sharekit" version = "0.3.1" @@ -8896,7 +9287,7 @@ dependencies = [ "branches", "bumpalo", "bytemuck", - "cfg_aliases", + "cfg_aliases 0.2.1", "cfg_block", "chrono", "crc32c", @@ -9102,6 +9493,15 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" +dependencies = [ + "libc", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -9113,6 +9513,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unarray" version = "0.1.4" @@ -9219,6 +9628,34 @@ dependencies = [ "typenum", ] +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -9262,6 +9699,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 5c4e573b..f8702d11 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -99,6 +99,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", # WebView). Pure-Rust crate, ships to every Tauri target. mobi = "0.8" +# Crash/error reporting. `tauri-plugin-sentry` injects @sentry/browser into +# every webview and routes browser + Rust panic events through one client; it +# re-exports sentry-rust-minidump for desktop native crashes. `rustls` avoids +# the native-tls/OpenSSL system dependency so the transport builds for +# Android/iOS. Native mobile crashes are handled by sentry-android/-cocoa. +sentry = { version = "0.42", default-features = false, features = [ + "backtrace", + "contexts", + "panic", + "reqwest", + "rustls", +] } +tauri-plugin-sentry = "0.5" + [target."cfg(target_os = \"macos\")".dependencies] rand = "0.8" cocoa = "0.25" diff --git a/apps/readest-app/src-tauri/build.rs b/apps/readest-app/src-tauri/build.rs index 46afa548..39878be1 100644 --- a/apps/readest-app/src-tauri/build.rs +++ b/apps/readest-app/src-tauri/build.rs @@ -1,4 +1,8 @@ -use std::{env, fs, path::PathBuf, process::Command}; +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; fn main() { println!("cargo:rerun-if-changed=../extensions/windows-thumbnail/src"); @@ -7,9 +11,60 @@ fn main() { build_windows_thumbnail(); } + propagate_sentry_dsn(); + tauri_build::build() } +/// Bake the Sentry DSN into the crate at build time via `cargo:rustc-env`, so +/// `option_env!("SENTRY_DSN")` (and, on iOS, the `readest_sentry_dsn` FFI) sees +/// it. Precedence: an existing `SENTRY_DSN` in the environment (CI secret / shell +/// export) wins; otherwise fall back to the gitignored `.env.local`, then `.env`, +/// at the app root. Absent everywhere => unset, so reporting stays disabled for +/// local and fork builds. `rerun-if-*` makes cargo recompile when the value or +/// the dotenv files change (avoiding a stale baked-in value). +fn propagate_sentry_dsn() { + println!("cargo:rerun-if-env-changed=SENTRY_DSN"); + let app_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join(".."); + let env_local = app_dir.join(".env.local"); + let env_file = app_dir.join(".env"); + println!("cargo:rerun-if-changed={}", env_local.display()); + println!("cargo:rerun-if-changed={}", env_file.display()); + + let dsn = env::var("SENTRY_DSN") + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| read_env_value(&env_local, "SENTRY_DSN")) + .or_else(|| read_env_value(&env_file, "SENTRY_DSN")); + + if let Some(dsn) = dsn { + println!("cargo:rustc-env=SENTRY_DSN={dsn}"); + } +} + +/// Read a single `KEY=value` from a dotenv-style file, skipping blank lines and +/// `#` comments and stripping surrounding quotes. `None` if the file/key is +/// absent or the value is empty. +fn read_env_value(path: &Path, key: &str) -> Option { + let contents = fs::read_to_string(path).ok()?; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(value) = line + .strip_prefix(key) + .and_then(|rest| rest.trim_start().strip_prefix('=')) + { + let value = value.trim().trim_matches(|c| c == '"' || c == '\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + fn build_windows_thumbnail() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let dll_crate_dir = manifest_dir diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index a9fd7bad..acf5ca9b 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -188,6 +188,7 @@ "device-info:default", "turso:default", "native-tts:default", - "native-bridge:default" + "native-bridge:default", + "sentry:default" ] } diff --git a/apps/readest-app/src-tauri/gen/android/app/build.gradle.kts b/apps/readest-app/src-tauri/gen/android/app/build.gradle.kts index f8c98f67..23363a69 100644 --- a/apps/readest-app/src-tauri/gen/android/app/build.gradle.kts +++ b/apps/readest-app/src-tauri/gen/android/app/build.gradle.kts @@ -24,6 +24,20 @@ android { } defaultConfig { manifestPlaceholders["usesCleartextTraffic"] = "false" + // Sentry DSN precedence: environment (CI secret / shell export) wins, + // else the gitignored .env.local, else .env at the app root (../../../ + // from this module). Empty => Sentry auto-init no-ops. + manifestPlaceholders["sentryDsn"] = System.getenv("SENTRY_DSN")?.takeIf { it.isNotBlank() } + ?: listOf("../../../.env.local", "../../../.env") + .map { rootProject.file(it) } + .filter { it.exists() } + .firstNotNullOfOrNull { f -> + f.readLines() + .map { it.trim() } + .firstOrNull { it.startsWith("SENTRY_DSN=") } + ?.substringAfter("=")?.trim()?.trim('"', '\'')?.takeIf { it.isNotEmpty() } + } + ?: "" applicationId = "com.bilingify.readest" minSdk = 26 targetSdk = 36 @@ -88,6 +102,15 @@ dependencies { implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.google.android.material:material:1.12.0") implementation("androidx.lifecycle:lifecycle-process:2.10.0") + implementation("io.sentry:sentry-android:8.47.0") { + // androidx.lifecycle:lifecycle-common-java8 was merged into + // lifecycle-common and is no longer published at 2.9.0+. The project + // pins androidx.lifecycle to 2.10.0 (lifecycle-process above), which + // version-aligns this Sentry transitive to a nonexistent 2.10.0 and + // breaks dependency resolution. The Java8 lifecycle APIs Sentry uses now + // live in lifecycle-common, pulled in transitively via lifecycle-process. + exclude(group = "androidx.lifecycle", module = "lifecycle-common-java8") + } testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index fa901b8c..8bb5dd20 100644 --- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,12 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc" /> + + + + +@import Sentry; + +// Provided by the Rust static library (see src-tauri/src/sentry_config.rs). +extern const char *readest_sentry_dsn(void); + +// Starts sentry-cocoa at class-load time (before main), so native iOS crashes +// are captured from launch without editing the generated main.mm / Info.plist. +// The DSN comes from the Rust side (single SENTRY_DSN build-time source); a null +// or empty DSN leaves the native SDK disabled, so local and fork builds do not +// report. Crashes + errors only: no tracing, no PII. +@interface ReadestSentryBootstrap : NSObject +@end + +@implementation ReadestSentryBootstrap + ++ (void)load { + const char *dsn = readest_sentry_dsn(); + if (dsn == NULL || dsn[0] == '\0') { + return; + } + NSString *dsnString = [NSString stringWithUTF8String:dsn]; + [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { + options.dsn = dsnString; + options.environment = @"production"; + options.tracesSampleRate = @0.0; + options.sendDefaultPii = NO; + }]; +} + +@end diff --git a/apps/readest-app/src-tauri/gen/apple/project.yml b/apps/readest-app/src-tauri/gen/apple/project.yml index 761d09f7..b230b5a7 100644 --- a/apps/readest-app/src-tauri/gen/apple/project.yml +++ b/apps/readest-app/src-tauri/gen/apple/project.yml @@ -4,6 +4,10 @@ options: deploymentTarget: iOS: 15.0 fileGroups: [../../src] +packages: + Sentry: + url: https://github.com/getsentry/sentry-cocoa + from: 8.0.0 configs: debug: debug release: release @@ -45,6 +49,7 @@ targets: # than ship a malformed one. IAP works without it. sources: - path: Sources + - path: SentrySupport - path: Assets.xcassets # Only reference Externals as a file group (no compile/resource # phases) — the actual libapp.a lookup happens via the @@ -92,6 +97,8 @@ targets: EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 groups: [app] dependencies: + - package: Sentry + product: Sentry - framework: libapp.a embed: false - target: ShareExtension diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 32208b8d..996afcf9 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod mobi_parser; mod nightly_update; mod parser_common; mod range_file; +mod sentry_config; #[cfg(desktop)] mod spawn_fresh_browser; mod transfer_file; @@ -302,6 +303,30 @@ struct SingleInstancePayload { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Initialize Sentry as early as possible so panics during startup are + // captured. `None` DSN (unset SENTRY_DSN) => disabled, so local and fork + // builds don't report. Desktop also starts the out-of-process minidump + // handler for native crashes; on mobile, native crashes belong to the + // sentry-android / sentry-cocoa SDKs. The guard must outlive the app, so it + // is held until `run()` returns (after the blocking `.run(...)` call). + let sentry_guard = sentry_config::sentry_dsn().map(|dsn| { + sentry::init(( + dsn, + sentry::ClientOptions { + release: sentry::release_name!(), + environment: Some(sentry_config::sentry_environment().into()), + traces_sample_rate: 0.0, + send_default_pii: false, + ..Default::default() + }, + )) + }); + + #[cfg(not(any(target_os = "ios", target_os = "android")))] + let _minidump_guard = sentry_guard + .as_ref() + .map(|guard| tauri_plugin_sentry::minidump::init(guard)); + let builder = tauri::Builder::default() .plugin( tauri_plugin_log::Builder::new() @@ -417,6 +442,11 @@ pub fn run() { #[cfg(feature = "webdriver")] let builder = builder.plugin(tauri_plugin_webdriver::init()); + let builder = match sentry_guard.as_ref() { + Some(client) => builder.plugin(tauri_plugin_sentry::init(client)), + None => builder, + }; + builder .setup(|#[allow(unused_variables)] app| { // When running with the webdriver feature (E2E/integration tests), diff --git a/apps/readest-app/src-tauri/src/sentry_config.rs b/apps/readest-app/src-tauri/src/sentry_config.rs new file mode 100644 index 00000000..ad69b569 --- /dev/null +++ b/apps/readest-app/src-tauri/src/sentry_config.rs @@ -0,0 +1,82 @@ +//! Pure helpers for configuring Sentry. Kept dependency-free and unit-tested; +//! the actual `sentry::init` call and plugin registration live in `lib.rs`. + +/// Normalizes a raw `SENTRY_DSN` value. Unset, empty, or whitespace-only maps to +/// `None` so local and fork builds (no DSN) never report. +pub fn dsn_from_env(raw: Option<&str>) -> Option<&str> { + match raw { + Some(dsn) if !dsn.trim().is_empty() => Some(dsn.trim()), + _ => None, + } +} + +/// Compile-time Sentry DSN from the `SENTRY_DSN` env var (baked at build time). +pub fn sentry_dsn() -> Option<&'static str> { + dsn_from_env(option_env!("SENTRY_DSN")) +} + +/// Sentry `environment` tag derived from the crate version. Nightly builds use a +/// single 10-digit `YYYYMMDDHH` prerelease (e.g. `0.11.17-2026070301`); +/// everything else is treated as production. +pub fn environment_for_version(version: &str) -> &'static str { + if let Some((_, pre)) = version.split_once('-') { + if pre.len() == 10 && pre.bytes().all(|b| b.is_ascii_digit()) { + return "nightly"; + } + } + "production" +} + +/// Sentry `environment` for the current build. +pub fn sentry_environment() -> &'static str { + environment_for_version(env!("CARGO_PKG_VERSION")) +} + +/// C-ABI accessor for the compile-time Sentry DSN, used by the iOS native +/// bootstrap (sentry-cocoa) so it starts with the same DSN as the Rust client +/// without a second env read or fragile Info.plist / preprocessor plumbing. +/// Returns a NUL-terminated pointer valid for the process lifetime, or null when +/// no DSN is configured (empty `SENTRY_DSN` => the native SDK stays disabled). +#[cfg(target_os = "ios")] +#[no_mangle] +pub extern "C" fn readest_sentry_dsn() -> *const std::os::raw::c_char { + use std::ffi::CString; + use std::sync::OnceLock; + static DSN: OnceLock> = OnceLock::new(); + match DSN.get_or_init(|| sentry_dsn().and_then(|d| CString::new(d).ok())) { + Some(cstr) => cstr.as_ptr(), + None => std::ptr::null(), + } +} + +#[cfg(test)] +mod tests { + use super::{dsn_from_env, environment_for_version}; + + #[test] + fn dsn_is_none_when_unset_or_blank() { + assert_eq!(dsn_from_env(None), None); + assert_eq!(dsn_from_env(Some("")), None); + assert_eq!(dsn_from_env(Some(" ")), None); + } + + #[test] + fn dsn_is_trimmed_when_present() { + assert_eq!( + dsn_from_env(Some(" https://k@o.ingest.sentry.io/1 ")), + Some("https://k@o.ingest.sentry.io/1") + ); + } + + #[test] + fn environment_is_nightly_for_ten_digit_prerelease() { + assert_eq!(environment_for_version("0.11.17-2026070301"), "nightly"); + } + + #[test] + fn environment_is_production_otherwise() { + assert_eq!(environment_for_version("0.11.17"), "production"); + assert_eq!(environment_for_version("0.11.17-rc.1"), "production"); + assert_eq!(environment_for_version("1.0.0-2026"), "production"); + } +}