diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e89dd2f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + name: wx-macos-arm64 + - os: macos-latest + target: x86_64-apple-darwin + name: wx-macos-x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: wx-linux-x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + name: wx-windows-x86_64.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Rename binary (Unix) + if: matrix.os != 'windows-latest' + run: | + cp target/${{ matrix.target }}/release/wx ${{ matrix.name }} + + - name: Rename binary (Windows) + if: matrix.os == 'windows-latest' + run: | + copy target\${{ matrix.target }}\release\wx.exe ${{ matrix.name }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: ${{ matrix.name }} + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: ${{ matrix.name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..683b414 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1332 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interprocess" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be5e5c847dbdb44564bd85294740d031f4f8aeb3464e5375ef7141f7538db69" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wx" +version = "0.1.0" +dependencies = [ + "aes", + "anyhow", + "cbc", + "chrono", + "clap", + "dirs", + "hmac", + "interprocess", + "libc", + "md5", + "pbkdf2", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "windows", + "zstd", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..75aa5f4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "wx" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "wx" +path = "src/main.rs" + +[dependencies] +# CLI +clap = { version = "4", features = ["derive"] } + +# 异步 +tokio = { version = "1", features = ["full"] } + +# 序列化 +serde = { version = "1", features = ["derive"] } +serde_json = "=1.0.140" + +# SQLite +rusqlite = { version = "0.31", features = ["bundled"] } + +# 加密 +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +hmac = "0.12" +sha2 = "0.10" +pbkdf2 = "0.12" + +# 解压 +zstd = "0.13" + +# IPC (Unix socket + Windows named pipe 统一) +interprocess = { version = "2", features = ["tokio"] } + +# 错误处理 +anyhow = "1" + +# 时间 +chrono = { version = "0.4", features = ["serde"] } + +# 跨平台路径 +dirs = "5" + +# MD5 (联系人表名 Msg_) +md5 = "0.7" + +# 正则表达式 +regex = "1" + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_System_Diagnostics_Debug", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", + "Win32_Foundation", + "Win32_System_Memory", +] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/src/cli/contacts.rs b/src/cli/contacts.rs new file mode 100644 index 0000000..c32ee3d --- /dev/null +++ b/src/cli/contacts.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_contacts(query: Option, limit: usize, json: bool) -> Result<()> { + let req = Request::Contacts { query, limit }; + let resp = transport::send(req)?; + + let contacts = resp.data.get("contacts") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let total = resp.data["total"].as_i64().unwrap_or(contacts.len() as i64); + + if json { + println!("{}", serde_json::to_string_pretty(&contacts)?); + return Ok(()); + } + + println!("共 {} 个联系人(显示 {} 个):\n", total, contacts.len()); + for c in &contacts { + let display = c["display"].as_str().unwrap_or(""); + let username = c["username"].as_str().unwrap_or(""); + println!(" {:<20} {}", display, username); + } + + Ok(()) +} diff --git a/src/cli/daemon_cmd.rs b/src/cli/daemon_cmd.rs new file mode 100644 index 0000000..44e6660 --- /dev/null +++ b/src/cli/daemon_cmd.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use crate::config; +use crate::cli::DaemonCommands; +use crate::cli::transport; + +pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> { + match cmd { + DaemonCommands::Status => cmd_status(), + DaemonCommands::Stop => cmd_stop(), + DaemonCommands::Logs { follow, lines } => cmd_logs(follow, lines), + } +} + +fn cmd_status() -> Result<()> { + if transport::is_alive() { + let pid_path = config::pid_path(); + let pid = std::fs::read_to_string(&pid_path) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "?".into()); + println!("wx-daemon 运行中 (PID {})", pid); + } else { + println!("wx-daemon 未运行"); + } + Ok(()) +} + +fn cmd_stop() -> Result<()> { + let pid_path = config::pid_path(); + if !pid_path.exists() { + println!("daemon 未运行"); + return Ok(()); + } + + let pid_str = std::fs::read_to_string(&pid_path)?; + let pid: u32 = pid_str.trim().parse() + .map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?; + + #[cfg(unix)] + { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); } + println!("已停止 wx-daemon (PID {})", pid); + } + + #[cfg(windows)] + { + std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output()?; + println!("已停止 wx-daemon (PID {})", pid); + } + + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(&pid_path); + + Ok(()) +} + +fn cmd_logs(follow: bool, lines: usize) -> Result<()> { + let log_path = config::log_path(); + if !log_path.exists() { + println!("暂无日志"); + return Ok(()); + } + + if follow { + #[cfg(unix)] + { + std::process::Command::new("tail") + .args([&format!("-{}", lines), "-f", &log_path.to_string_lossy()]) + .status()?; + } + #[cfg(windows)] + { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(&log_path)?; + let len = file.seek(SeekFrom::End(0))?; + let start = len.saturating_sub((lines as u64) * 200); + file.seek(SeekFrom::Start(start))?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { println!("{}", line); } + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let mut buf = String::new(); + file.read_to_string(&mut buf)?; + if !buf.is_empty() { print!("{}", buf); } + } + } + } else { + let content = std::fs::read_to_string(&log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { println!("{}", line); } + } + + Ok(()) +} diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 0000000..5490dd7 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; +use super::history::{parse_time, parse_time_end}; + +pub fn cmd_export( + chat: String, + since: Option, + until: Option, + limit: usize, + format: String, + output: Option, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::History { + chat, + limit, + offset: 0, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + let messages = resp.data["messages"].as_array().cloned().unwrap_or_default(); + let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string(); + let is_group = resp.data["is_group"].as_bool().unwrap_or(false); + let count = messages.len(); + + let text = match format.as_str() { + "json" => serde_json::to_string_pretty(&resp.data)?, + "txt" => { + let group_str = if is_group { "[群]" } else { "" }; + let mut lines = vec![format!("=== {}{} ({} 条) ===\n", chat_name, group_str, count)]; + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or(""); + let sender_str = if !sender.is_empty() { format!("{}: ", sender) } else { String::new() }; + lines.push(format!("[{}] {}{}", time, sender_str, content)); + } + lines.join("\n") + } + _ => { + // markdown (default) + let group_str = if is_group { "(群聊)" } else { "" }; + let mut lines = vec![ + format!("# {}{}", chat_name, group_str), + format!("\n> 导出 {} 条消息\n", count), + ]; + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> "); + let sender_md = if !sender.is_empty() { format!("**{}**: ", sender) } else { String::new() }; + lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content)); + } + lines.join("\n") + } + }; + + match output { + Some(path) => { + std::fs::write(&path, &text)?; + println!("已导出 {} 条消息到 {}", count, path); + } + None => println!("{}", text), + } + + Ok(()) +} diff --git a/src/cli/history.rs b/src/cli/history.rs new file mode 100644 index 0000000..3929a76 --- /dev/null +++ b/src/cli/history.rs @@ -0,0 +1,80 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_history( + chat: String, + limit: usize, + offset: usize, + since: Option, + until: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(|s| parse_time_end(s)).transpose()?; + + let req = Request::History { + chat, + limit, + offset, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + + if json { + let msgs = resp.data.get("messages").cloned().unwrap_or(serde_json::Value::Array(vec![])); + println!("{}", serde_json::to_string_pretty(&msgs)?); + return Ok(()); + } + + let chat_name = resp.data["chat"].as_str().unwrap_or(""); + let is_group = resp.data["is_group"].as_bool().unwrap_or(false); + let count = resp.data["count"].as_i64().unwrap_or(0); + let group_str = if is_group { " [群]" } else { "" }; + println!("=== {}{} ({} 条) ===\n", chat_name, group_str, count); + + if let Some(msgs) = resp.data["messages"].as_array() { + for m in msgs { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or(""); + + let sender_str = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}", time, sender_str, content); + } + } + + Ok(()) +} + +pub fn parse_time(s: &str) -> Result { + for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) { + return Ok(dt.and_utc().timestamp()); + } + // 尝试仅日期格式 + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) { + let dt = d.and_hms_opt(0, 0, 0).unwrap(); + return Ok(dt.and_utc().timestamp()); + } + } + anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s) +} + +pub fn parse_time_end(s: &str) -> Result { + // 对于仅日期格式,结束时间为当天 23:59:59 + if s.len() == 10 { + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = d.and_hms_opt(23, 59, 59).unwrap(); + return Ok(dt.and_utc().timestamp()); + } + } + parse_time(s) +} diff --git a/src/cli/init.rs b/src/cli/init.rs new file mode 100644 index 0000000..b80b1ec --- /dev/null +++ b/src/cli/init.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use serde_json::json; +use std::collections::HashMap; + +use crate::config; +use crate::scanner; + +pub fn cmd_init(force: bool) -> Result<()> { + // 查找 config.json + let config_path = find_or_create_config_path(); + + // 检查是否已初始化 + if !force && config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(cfg) = serde_json::from_str::(&content) { + let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or(""); + let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json"); + let keys_path = if std::path::Path::new(keys_file).is_absolute() { + std::path::PathBuf::from(keys_file) + } else { + config_path.parent().unwrap_or(std::path::Path::new(".")) + .join(keys_file) + }; + if !db_dir.is_empty() && !db_dir.contains("your_wxid") + && std::path::Path::new(db_dir).exists() + && keys_path.exists() + { + println!("已初始化,数据目录: {}", db_dir); + println!("如需重新扫描密钥,使用 --force"); + return Ok(()); + } + } + } + } + + // Step 1: 检测 db_dir + println!("检测微信数据目录..."); + let db_dir = config::auto_detect_db_dir() + .context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?; + println!("找到数据目录: {}", db_dir.display()); + + // Step 2: 扫描密钥(需要 root/sudo) + println!("扫描加密密钥(需要 root 权限)..."); + let entries = scanner::scan_keys(&db_dir)?; + + // Step 3: 保存 all_keys.json + let keys_file_path = config_path.parent() + .unwrap_or(std::path::Path::new(".")) + .join("all_keys.json"); + + let mut keys_json = serde_json::Map::new(); + for entry in &entries { + keys_json.insert(entry.db_name.clone(), json!({ + "enc_key": entry.enc_key, + })); + } + std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?) + .context("写入 all_keys.json 失败")?; + println!("成功提取 {} 个数据库密钥", entries.len()); + println!("密钥已保存: {}", keys_file_path.display()); + + // Step 4: 保存 config.json + let mut cfg = HashMap::new(); + // 读取已有配置 + if config_path.exists() { + if let Ok(c) = std::fs::read_to_string(&config_path) { + if let Ok(v) = serde_json::from_str::>(&c) { + for (k, val) in v { + cfg.insert(k, val); + } + } + } + } + cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy())); + cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json")); + cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted")); + + std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) + .context("写入 config.json 失败")?; + println!("配置已保存: {}", config_path.display()); + println!("初始化完成,可以使用 wx sessions / wx history 等命令了"); + + Ok(()) +} + +fn find_or_create_config_path() -> std::path::PathBuf { + // 优先使用可执行文件同目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + return dir.join("config.json"); + } + } + std::env::current_dir() + .unwrap_or_default() + .join("config.json") +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..b5f45d3 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,169 @@ +mod init; +pub mod sessions; +pub mod history; +pub mod search; +pub mod contacts; +pub mod export; +pub mod watch; +pub mod daemon_cmd; +pub mod transport; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +/// wx — 微信本地数据 CLI +#[derive(Parser)] +#[command(name = "wx", version = "0.1.0", about = "wx — 微信本地数据 CLI")] +pub struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// 初始化:检测数据目录并扫描加密密钥 + Init { + /// 强制重新扫描(覆盖已有配置) + #[arg(long)] + force: bool, + }, + /// 列出最近会话 + Sessions { + /// 会话数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 查看聊天记录 + History { + /// 聊天对象名称(支持模糊匹配) + chat: String, + /// 消息数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 分页偏移 + #[arg(long, default_value = "0")] + offset: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 搜索消息 + Search { + /// 搜索关键词 + keyword: String, + /// 限定聊天(可多次指定) + #[arg(long = "in", value_name = "CHAT")] + chats: Vec, + /// 结果数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 查看联系人 + Contacts { + /// 按名字过滤 + #[arg(short = 'q', long)] + query: Option, + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 输出原始 JSON + #[arg(long)] + json: bool, + }, + /// 导出聊天记录到文件 + Export { + /// 聊天对象名称 + chat: String, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 最多导出条数 + #[arg(short = 'n', long, default_value = "500")] + limit: usize, + /// 输出格式 [markdown|txt|json] + #[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])] + format: String, + /// 输出文件(默认 stdout) + #[arg(short = 'o', long)] + output: Option, + }, + /// 实时监听新消息(Ctrl+C 退出) + Watch { + /// 只显示指定聊天的消息 + #[arg(long)] + chat: Option, + /// 输出 JSON lines + #[arg(long)] + json: bool, + }, + /// 管理 wx-daemon + Daemon { + #[command(subcommand)] + cmd: DaemonCommands, + }, +} + +#[derive(Subcommand)] +pub enum DaemonCommands { + /// 查看 daemon 运行状态 + Status, + /// 停止 daemon + Stop, + /// 查看 daemon 日志 + Logs { + /// 持续输出(tail -f) + #[arg(short = 'f', long)] + follow: bool, + /// 显示最近 N 行 + #[arg(short = 'n', long, default_value = "50")] + lines: usize, + }, +} + +pub fn run() { + let cli = Cli::parse(); + if let Err(e) = dispatch(cli) { + eprintln!("错误: {}", e); + std::process::exit(1); + } +} + +fn dispatch(cli: Cli) -> Result<()> { + match cli.command { + Commands::Init { force } => init::cmd_init(force), + Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json), + Commands::History { chat, limit, offset, since, until, json } => { + history::cmd_history(chat, limit, offset, since, until, json) + } + Commands::Search { keyword, chats, limit, since, until, json } => { + search::cmd_search(keyword, chats, limit, since, until, json) + } + Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json), + Commands::Export { chat, since, until, limit, format, output } => { + export::cmd_export(chat, since, until, limit, format, output) + } + Commands::Watch { chat, json } => watch::cmd_watch(chat, json), + Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), + } +} diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..59955a8 --- /dev/null +++ b/src/cli/search.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; +use super::history::{parse_time, parse_time_end}; + +pub fn cmd_search( + keyword: String, + chats: Vec, + limit: usize, + since: Option, + until: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let chats_opt = if chats.is_empty() { None } else { Some(chats) }; + + let req = Request::Search { + keyword: keyword.clone(), + chats: chats_opt, + limit, + since: since_ts, + until: until_ts, + }; + + let resp = transport::send(req)?; + let results = resp.data.get("results") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let count = resp.data["count"].as_i64().unwrap_or(results.len() as i64); + + if json { + println!("{}", serde_json::to_string_pretty(&results)?); + return Ok(()); + } + + println!("搜索 \"{}\",找到 {} 条:\n", keyword, count); + for r in &results { + let time = r["time"].as_str().unwrap_or(""); + let chat = r["chat"].as_str().unwrap_or(""); + let sender = r["sender"].as_str().unwrap_or(""); + let content = r["content"].as_str().unwrap_or(""); + + let chat_str = if !chat.is_empty() { + format!("\x1b[36m[{}]\x1b[0m ", chat) + } else { + String::new() + }; + let sender_str = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}{}", time, chat_str, sender_str, content); + } + + Ok(()) +} diff --git a/src/cli/sessions.rs b/src/cli/sessions.rs new file mode 100644 index 0000000..8721ad3 --- /dev/null +++ b/src/cli/sessions.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> { + let resp = transport::send(Request::Sessions { limit })?; + let data = resp.data.get("sessions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if json { + println!("{}", serde_json::to_string_pretty(&data)?); + return Ok(()); + } + + for s in &data { + let time = s["time"].as_str().unwrap_or(""); + let chat = s["chat"].as_str().unwrap_or(""); + let is_group = s["is_group"].as_bool().unwrap_or(false); + let unread = s["unread"].as_i64().unwrap_or(0); + let msg_type = s["last_msg_type"].as_str().unwrap_or(""); + let sender = s["last_sender"].as_str().unwrap_or(""); + let summary = s["summary"].as_str().unwrap_or(""); + + let unread_str = if unread > 0 { + format!(" \x1b[31m({}未读)\x1b[0m", unread) + } else { + String::new() + }; + let group_str = if is_group { " [群]" } else { "" }; + let sender_str = if !sender.is_empty() { + format!("{}: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m \x1b[1m{}\x1b[0m{}{}", time, chat, group_str, unread_str); + println!(" {}: {}{}", msg_type, sender_str, summary); + println!(); + } + + Ok(()) +} diff --git a/src/cli/transport.rs b/src/cli/transport.rs new file mode 100644 index 0000000..7f54e63 --- /dev/null +++ b/src/cli/transport.rs @@ -0,0 +1,174 @@ +use anyhow::{bail, Context, Result}; +use std::io::{BufRead, BufReader, Write}; +use std::time::Duration; + +use crate::config; +use crate::ipc::{Request, Response}; + +const STARTUP_TIMEOUT_SECS: u64 = 15; + +/// 检查 daemon 是否存活 +pub fn is_alive() -> bool { + #[cfg(unix)] + { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + if !sock_path.exists() { + return false; + } + let mut stream = match UnixStream::connect(&sock_path) { + Ok(s) => s, + Err(_) => return false, + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + + let req = serde_json::json!({"cmd": "ping"}); + if write!(stream, "{}\n", req).is_err() { + return false; + } + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + if reader.read_line(&mut line).is_err() { + return false; + } + serde_json::from_str::(&line) + .ok() + .and_then(|v| v.get("pong").and_then(|p| p.as_bool())) + .unwrap_or(false) + } + #[cfg(windows)] + { + // 通过 named pipe 检测 + let pipe_path = r"\\.\pipe\wechat-cli-daemon"; + use std::fs::OpenOptions; + OpenOptions::new().read(true).write(true).open(pipe_path).is_ok() + } + #[cfg(not(any(unix, windows)))] + { + false + } +} + +/// 确保 daemon 运行,必要时自动启动 +pub fn ensure_daemon() -> Result<()> { + if is_alive() { + return Ok(()); + } + eprintln!("启动 wx-daemon..."); + start_daemon()?; + Ok(()) +} + +/// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1) +fn start_daemon() -> Result<()> { + let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; + + #[cfg(unix)] + { + let _ = std::process::Command::new(&exe) + .env("WX_DAEMON_MODE", "1") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("无法启动 daemon 进程")?; + } + + #[cfg(windows)] + { + let _ = std::process::Command::new(&exe) + .env("WX_DAEMON_MODE", "1") + .creation_flags(0x00000008) // DETACHED_PROCESS + .spawn() + .context("无法启动 daemon 进程")?; + } + + // 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒) + let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(300)); + if is_alive() { + return Ok(()); + } + } + + bail!( + "wx-daemon 启动超时(>{}s)\n请查看日志: {}", + STARTUP_TIMEOUT_SECS, + config::log_path().display() + ) +} + +/// 向 daemon 发送请求并返回响应 +pub fn send(req: Request) -> Result { + ensure_daemon()?; + + #[cfg(unix)] + { + send_unix(req) + } + #[cfg(windows)] + { + send_windows(req) + } + #[cfg(not(any(unix, windows)))] + { + bail!("不支持当前平台") + } +} + +#[cfg(unix)] +fn send_unix(req: Request) -> Result { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + let mut stream = UnixStream::connect(&sock_path) + .context("连接 daemon socket 失败")?; + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(30))).ok(); + + let req_str = serde_json::to_string(&req)? + "\n"; + stream.write_all(req_str.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line) + .context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} + +#[cfg(windows)] +fn send_windows(req: Request) -> Result { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + + let pipe_path = r"\\.\pipe\wechat-cli-daemon"; + let mut pipe = OpenOptions::new() + .read(true) + .write(true) + .open(pipe_path) + .context("连接 daemon named pipe 失败")?; + + let req_str = serde_json::to_string(&req)? + "\n"; + pipe.write_all(req_str.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(pipe); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line) + .context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..cc8468d --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,89 @@ +use anyhow::Result; +use std::io::BufRead; + +use crate::ipc::Request; +use super::super::cli::transport; + +pub fn cmd_watch(chat: Option, json: bool) -> Result<()> { + transport::ensure_daemon()?; + + let sock_path = crate::config::sock_path(); + + // 连接 socket + #[cfg(unix)] + let mut stream = { + use std::os::unix::net::UnixStream; + UnixStream::connect(&sock_path)? + }; + + // 发送 watch 请求 + let req_line = serde_json::to_string(&Request::Watch)? + "\n"; + #[cfg(unix)] + { + use std::io::Write; + stream.write_all(req_line.as_bytes())?; + } + + if !json { + eprintln!("监听中(Ctrl+C 退出)...\n"); + } + + #[cfg(unix)] + { + let reader = std::io::BufReader::new(stream.try_clone()?); + for line_result in reader.lines() { + let line = match line_result { + Ok(l) => l, + Err(_) => break, + }; + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + let event: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let evt = event["event"].as_str().unwrap_or(""); + if evt == "connected" || evt == "heartbeat" { + continue; + } + + // 过滤指定聊天 + if let Some(ref filter_chat) = chat { + let event_chat = event["chat"].as_str().unwrap_or(""); + let event_user = event["username"].as_str().unwrap_or(""); + if event_chat != filter_chat && event_user != filter_chat { + continue; + } + } + + if json { + println!("{}", line); + continue; + } + + let time_s = event["time"].as_str().unwrap_or(""); + let chat_s = event["chat"].as_str().unwrap_or(""); + let is_group = event["is_group"].as_bool().unwrap_or(false); + let sender = event["sender"].as_str().unwrap_or(""); + let content = event["content"].as_str().unwrap_or(""); + + let chat_part = if is_group { + format!("\x1b[36m[{}]\x1b[0m ", chat_s) + } else { + format!("\x1b[1m{}\x1b[0m ", chat_s) + }; + let sender_part = if !sender.is_empty() { + format!("\x1b[33m{}\x1b[0m: ", sender) + } else { + String::new() + }; + + println!("\x1b[90m[{}]\x1b[0m {}{}{}", time_s, chat_part, sender_part, content); + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2e447a6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,274 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub db_dir: PathBuf, + pub keys_file: PathBuf, + pub decrypted_dir: PathBuf, + #[serde(default)] + pub wechat_process: String, +} + +/// 从 /config.json 或 $HOME/.wechat-cli/config.json 加载配置 +pub fn load_config() -> Result { + let config_path = find_config_file()?; + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?; + let raw: serde_json::Value = serde_json::from_str(&content) + .with_context(|| "config.json 格式错误")?; + + let db_dir = raw.get("db_dir") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(default_db_dir); + + let base_dir = config_path.parent().unwrap_or(Path::new(".")); + + let keys_file = raw.get("keys_file") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("all_keys.json")); + + let decrypted_dir = raw.get("decrypted_dir") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("decrypted")); + + let wechat_process = raw.get("wechat_process") + .and_then(|v| v.as_str()) + .unwrap_or(default_wechat_process()) + .to_string(); + + Ok(Config { + db_dir, + keys_file, + decrypted_dir, + wechat_process, + }) +} + +/// 保存配置到文件 +pub fn save_config(config: &Config) -> Result<()> { + let config_path = find_config_file().unwrap_or_else(|_| { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("config.json") + }); + let content = serde_json::to_string_pretty(config)?; + std::fs::write(&config_path, content) + .with_context(|| format!("写入 config.json 失败: {}", config_path.display()))?; + Ok(()) +} + +fn find_config_file() -> Result { + // 1. 优先查找可执行文件同目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("config.json"); + if p.exists() { + return Ok(p); + } + } + } + // 2. 当前工作目录 + let cwd = std::env::current_dir().unwrap_or_default().join("config.json"); + if cwd.exists() { + return Ok(cwd); + } + // 3. ~/.wechat-cli/config.json + if let Some(home) = dirs::home_dir() { + let p = home.join(".wechat-cli").join("config.json"); + if p.exists() { + return Ok(p); + } + } + // 返回默认路径(可能不存在,调用方负责处理) + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + return Ok(dir.join("config.json")); + } + } + Ok(PathBuf::from("config.json")) +} + +pub fn cli_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".wechat-cli") +} + +pub fn sock_path() -> PathBuf { + cli_dir().join("daemon.sock") +} + +pub fn pid_path() -> PathBuf { + cli_dir().join("daemon.pid") +} + +pub fn log_path() -> PathBuf { + cli_dir().join("daemon.log") +} + +pub fn cache_dir() -> PathBuf { + cli_dir().join("cache") +} + +pub fn mtime_file() -> PathBuf { + cache_dir().join("_mtimes.json") +} + +fn default_db_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files") + } + #[cfg(target_os = "linux")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Documents/xwechat_files") + } + #[cfg(target_os = "windows")] + { + PathBuf::from(std::env::var("APPDATA").unwrap_or_default()) + .join("Tencent/xwechat") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::from(".") + } +} + +fn default_wechat_process() -> &'static str { + #[cfg(target_os = "macos")] + { "WeChat" } + #[cfg(target_os = "linux")] + { "wechat" } + #[cfg(target_os = "windows")] + { "Weixin.exe" } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { "WeChat" } +} + +/// 自动检测微信 db_storage 目录 +pub fn auto_detect_db_dir() -> Option { + detect_db_dir_impl() +} + +#[cfg(target_os = "macos")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + // 支持 sudo 环境 + let home = if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if !sudo_user.is_empty() { + PathBuf::from("/Users").join(&sudo_user) + } else { + home + } + } else { + home + }; + + let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); + if !base.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "linux")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + let sudo_home = std::env::var("SUDO_USER").ok() + .filter(|s| !s.is_empty()) + .map(|u| PathBuf::from("/home").join(u)); + + let mut candidates: Vec = Vec::new(); + for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { + let xwechat = base_home.join("Documents/xwechat_files"); + if xwechat.exists() { + if let Ok(entries) = std::fs::read_dir(&xwechat) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + let old = base_home.join(".local/share/weixin/data/db_storage"); + if old.is_dir() { + candidates.push(old); + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "windows")] +fn detect_db_dir_impl() -> Option { + let appdata = std::env::var("APPDATA").ok()?; + let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config"); + if !config_dir.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&config_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "ini").unwrap_or(false) { + if let Ok(content) = std::fs::read_to_string(&path) { + let data_root = content.trim().to_string(); + if PathBuf::from(&data_root).is_dir() { + let pattern = PathBuf::from(&data_root) + .join("xwechat_files"); + if let Ok(entries2) = std::fs::read_dir(&pattern) { + for entry2 in entries2.flatten() { + let storage = entry2.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + } + } + } + } + candidates.into_iter().next() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn detect_db_dir_impl() -> Option { + None +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..1c09f4d --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,106 @@ +pub mod wal; + +use anyhow::{bail, Result}; +use aes::Aes256; +use cbc::Decryptor; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use std::path::Path; + +pub const PAGE_SZ: usize = 4096; +pub const SALT_SZ: usize = 16; +pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64) + +/// SQLite 文件头魔数(16字节) +pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00"; + +type Aes256CbcDec = Decryptor; + +/// 解密单个 SQLCipher 4 页 +/// +/// - `enc_key`: 32字节 AES 密钥 +/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节) +/// - `pgno`: 页码(从1开始) +/// +/// 返回解密后的完整页面(PAGE_SZ 字节) +pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result> { + if page_data.len() < PAGE_SZ { + bail!("页面数据不足 {} 字节", PAGE_SZ); + } + + // IV 位于页面末尾 RESERVE_SZ 区域的前16字节 + let iv_offset = PAGE_SZ - RESERVE_SZ; + let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16] + .try_into() + .expect("IV 长度固定为 16"); + + let mut result = vec![0u8; PAGE_SZ]; + + if pgno == 1 { + // 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + // 写入 SQLite 文件头 + result[..16].copy_from_slice(SQLITE_HDR); + // 写入解密数据(从第16字节开始) + result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + // (已经是零,无需显式操作) + } else { + // 其他页:解密 [0..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + } + + Ok(result) +} + +/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding) +fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result> { + if data.is_empty() || data.len() % 16 != 0 { + bail!("密文长度不是 AES 块大小的倍数: {}", data.len()); + } + let mut buf = data.to_vec(); + // 使用 raw 模式不处理 padding + Aes256CbcDec::new(key.into(), iv.into()) + .decrypt_blocks_mut(unsafe { + std::slice::from_raw_parts_mut( + buf.as_mut_ptr() as *mut aes::cipher::Block, + buf.len() / 16, + ) + }); + Ok(buf) +} + +/// 完整解密一个 SQLCipher 数据库文件 +/// +/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path` +pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + let data = std::fs::read(db_path)?; + if data.is_empty() { + bail!("数据库文件为空: {}", db_path.display()); + } + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ; + let mut out = Vec::with_capacity(data.len()); + + for pgno in 1..=total_pages { + let offset = (pgno - 1) * PAGE_SZ; + let end = std::cmp::min(offset + PAGE_SZ, data.len()); + let mut page = data[offset..end].to_vec(); + // 不足一页则补零 + if page.len() < PAGE_SZ { + page.resize(PAGE_SZ, 0); + } + let dec = decrypt_page(enc_key, &page, pgno as u32)?; + out.extend_from_slice(&dec); + } + + std::fs::write(out_path, &out)?; + Ok(()) +} diff --git a/src/crypto/wal.rs b/src/crypto/wal.rs new file mode 100644 index 0000000..abd7fc0 --- /dev/null +++ b/src/crypto/wal.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use std::io::{SeekFrom, Seek, Write}; +use std::path::Path; + +use super::{decrypt_page, PAGE_SZ}; + +pub const WAL_HDR_SZ: usize = 32; +pub const WAL_FRAME_HDR: usize = 24; + +/// 将 WAL 文件中的变更应用到已解密的数据库文件 +/// +/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密): +/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes) +/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + if !wal_path.exists() { + return Ok(()); + } + + let wal_data = std::fs::read(wal_path)?; + if wal_data.len() <= WAL_HDR_SZ { + return Ok(()); + } + + // 读取 WAL 头中的 salt1 / salt2 + let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap()); + let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap()); + + let frame_size = WAL_FRAME_HDR + PAGE_SZ; + let frame_area = &wal_data[WAL_HDR_SZ..]; + + // 打开输出文件做随机写 + let mut db_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(out_path)?; + + let mut pos = 0usize; + while pos + frame_size <= frame_area.len() { + let fh = &frame_area[pos..pos + WAL_FRAME_HDR]; + let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size]; + + let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap()); + let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap()); + let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap()); + + pos += frame_size; + + // 跳过无效页码 + if pgno == 0 || pgno > 1_000_000 { + continue; + } + // salt 不匹配的帧属于已检查点或旧事务 + if fs1 != s1 || fs2 != s2 { + continue; + } + + let mut page_buf = page_data.to_vec(); + if page_buf.len() < PAGE_SZ { + page_buf.resize(PAGE_SZ, 0); + } + + let dec = decrypt_page(enc_key, &page_buf, pgno)?; + let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64; + db_file.seek(SeekFrom::Start(file_offset))?; + db_file.write_all(&dec)?; + } + + Ok(()) +} diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs new file mode 100644 index 0000000..6e7287c --- /dev/null +++ b/src/daemon/cache.rs @@ -0,0 +1,215 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::config; +use crate::crypto; +use crate::crypto::wal; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MtimeEntry { + db_mt: f64, + wal_mt: f64, + path: String, +} + +#[derive(Debug, Clone)] +struct CacheEntry { + db_mtime: f64, + wal_mtime: f64, + decrypted_path: PathBuf, +} + +/// 解密后数据库的 mtime-aware 缓存 +/// +/// 当数据库文件(.db)或 WAL 文件(.db-wal)的 mtime 发生变化时, +/// 自动重新解密并更新缓存。跨进程重启可通过持久化 mtime 文件复用已解密的 DB。 +pub struct DbCache { + db_dir: PathBuf, + cache_dir: PathBuf, + all_keys: HashMap, // rel_key -> enc_key(hex) + inner: Arc>>, +} + +impl DbCache { + pub async fn new( + db_dir: PathBuf, + all_keys: HashMap, + ) -> Result { + let cache_dir = config::cache_dir(); + tokio::fs::create_dir_all(&cache_dir).await?; + + let inner: HashMap = HashMap::new(); + let cache = DbCache { + db_dir, + cache_dir, + all_keys, + inner: Arc::new(Mutex::new(inner)), + }; + + cache.load_persistent().await; + Ok(cache) + } + + fn cache_file_path(&self, rel_key: &str) -> PathBuf { + let hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let short = &hash[..12]; + self.cache_dir.join(format!("{}.db", short)) + } + + /// 从持久化文件加载 mtime 记录,复用未过期的解密文件 + async fn load_persistent(&self) { + let mtime_file = config::mtime_file(); + let content = match tokio::fs::read_to_string(&mtime_file).await { + Ok(c) => c, + Err(_) => return, + }; + let saved: HashMap = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return, + }; + + let mut inner = self.inner.lock().await; + let mut reused = 0usize; + for (rel_key, entry) in &saved { + let dec_path = PathBuf::from(&entry.path); + if !dec_path.exists() { + continue; + } + let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR)); + let wal_path_str = format!("{}-wal", db_path.display()); + let wal_path = Path::new(&wal_path_str); + + let db_mt = mtime_f64(&db_path); + let wal_mt = if wal_path.exists() { mtime_f64(wal_path) } else { 0.0 }; + + if (db_mt - entry.db_mt).abs() < 0.001 && (wal_mt - entry.wal_mt).abs() < 0.001 { + inner.insert(rel_key.clone(), CacheEntry { + db_mtime: db_mt, + wal_mtime: wal_mt, + decrypted_path: dec_path, + }); + reused += 1; + } + } + if reused > 0 { + eprintln!("[cache] 复用 {} 个已解密 DB", reused); + } + } + + /// 持久化 mtime 记录 + async fn save_persistent(&self) { + let mtime_file = config::mtime_file(); + let inner = self.inner.lock().await; + let data: HashMap = inner.iter().map(|(k, v)| { + (k.clone(), MtimeEntry { + db_mt: v.db_mtime, + wal_mt: v.wal_mtime, + path: v.decrypted_path.to_string_lossy().into_owned(), + }) + }).collect(); + drop(inner); + + if let Ok(json) = serde_json::to_string_pretty(&data) { + let _ = tokio::fs::write(&mtime_file, json).await; + } + } + + /// 获取解密后的数据库路径 + /// + /// 如果 mtime 未变,直接返回缓存路径;否则重新解密 + pub async fn get(&self, rel_key: &str) -> Result> { + let enc_key_hex = match self.all_keys.get(rel_key) { + Some(k) => k.clone(), + None => return Ok(None), + }; + + let db_path = self.db_dir.join( + rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR) + .replace('/', std::path::MAIN_SEPARATOR_STR) + ); + if !db_path.exists() { + return Ok(None); + } + + let wal_path_str = format!("{}-wal", db_path.display()); + let wal_path = Path::new(&wal_path_str).to_path_buf(); + + let db_mt = mtime_f64(&db_path); + let wal_mt = if wal_path.exists() { mtime_f64(&wal_path) } else { 0.0 }; + + // 检查缓存 + { + let inner = self.inner.lock().await; + if let Some(entry) = inner.get(rel_key) { + if (entry.db_mtime - db_mt).abs() < 0.001 + && (entry.wal_mtime - wal_mt).abs() < 0.001 + && entry.decrypted_path.exists() + { + return Ok(Some(entry.decrypted_path.clone())); + } + } + } + + // 需要重新解密 + let out_path = self.cache_file_path(rel_key); + let enc_key_bytes = hex_to_32bytes(&enc_key_hex) + .with_context(|| format!("密钥格式错误: {}", rel_key))?; + + let t0 = std::time::Instant::now(); + let db_path2 = db_path.clone(); + let out_path2 = out_path.clone(); + let key_copy = enc_key_bytes; + tokio::task::spawn_blocking(move || { + crypto::full_decrypt(&db_path2, &out_path2, &key_copy) + }).await??; + + // 应用 WAL + if wal_path.exists() { + let out_path3 = out_path.clone(); + let wal_path3 = wal_path.clone(); + let key_copy2 = enc_key_bytes; + tokio::task::spawn_blocking(move || { + wal::apply_wal(&wal_path3, &out_path3, &key_copy2) + }).await??; + } + + let elapsed_ms = t0.elapsed().as_millis(); + eprintln!("[cache] 解密 {} ({}ms)", rel_key, elapsed_ms); + + // 更新内存缓存 + { + let mut inner = self.inner.lock().await; + inner.insert(rel_key.to_string(), CacheEntry { + db_mtime: db_mt, + wal_mtime: wal_mt, + decrypted_path: out_path.clone(), + }); + } + + self.save_persistent().await; + Ok(Some(out_path)) + } +} + +fn mtime_f64(path: &Path) -> f64 { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64()) + .unwrap_or(0.0) +} + +fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> { + if s.len() != 64 { + anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len()); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16) + .with_context(|| format!("非法 hex 字符 at {}", i * 2))?; + } + Ok(out) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..72243ad --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,280 @@ +pub mod cache; +pub mod query; +pub mod watcher; +pub mod server; + +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::broadcast; + +use crate::config; + +/// daemon 入口 +/// +/// 当 WX_DAEMON_MODE 环境变量设置时,main() 调用此函数 +pub fn run() { + let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime"); + if let Err(e) = rt.block_on(async_run()) { + eprintln!("[daemon] 启动失败: {}", e); + std::process::exit(1); + } +} + +async fn async_run() -> Result<()> { + // 确保工作目录存在 + let cli_dir = config::cli_dir(); + tokio::fs::create_dir_all(&cli_dir).await?; + tokio::fs::create_dir_all(config::cache_dir()).await?; + + // 写 PID 文件 + let pid = std::process::id(); + tokio::fs::write(config::pid_path(), pid.to_string()).await?; + + // 注册 SIGTERM / SIGINT 处理 + setup_signal_handler().await; + + eprintln!("[daemon] wx-daemon 启动 (PID {})", pid); + + // 加载配置 + let cfg = config::load_config()?; + eprintln!("[daemon] DB_DIR: {}", cfg.db_dir.display()); + + // 加载密钥 + let keys_content = tokio::fs::read_to_string(&cfg.keys_file).await + .map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?; + let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?; + let all_keys = extract_keys(&keys_raw); + eprintln!("[daemon] 密钥数量: {}", all_keys.len()); + + // 初始化 DbCache + let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?); + + // 收集消息 DB 列表 + let msg_db_keys: Vec = all_keys.keys() + .filter(|k| { + let k = k.replace('\\', "/"); + k.contains("message/message_") && k.ends_with(".db") + }) + .cloned() + .collect(); + + // 预热:加载联系人 + 解密 session.db + eprintln!("[daemon] 预热..."); + let names_raw = query::load_names(&*db).await.unwrap_or_else(|e| { + eprintln!("[daemon] 加载联系人失败: {}", e); + query::Names { + map: HashMap::new(), + md5_to_uname: HashMap::new(), + msg_db_keys: Vec::new(), + } + }); + let mut names = names_raw; + names.msg_db_keys = msg_db_keys; + + let _ = db.get("session/session.db").await; + eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len()); + + let names_arc = Arc::new(std::sync::RwLock::new(names)); + + // 启动 WAL watcher + let (watch_tx, _) = broadcast::channel::(500); + let session_wal = cfg.db_dir.join("session").join("session.db-wal"); + + // SAFETY: 我们确保 db 和 names_arc 在 daemon 生命周期内有效 + // 使用 Arc 传递引用避免 'static 问题 + let db_arc = Arc::clone(&db); + let names_arc2 = Arc::clone(&names_arc); + let tx_clone = watch_tx.clone(); + let session_wal2 = session_wal.clone(); + tokio::spawn(async move { + run_watcher(db_arc, names_arc2, tx_clone, session_wal2).await; + }); + + // 启动 IPC server(阻塞) + server::serve(Arc::clone(&db), Arc::clone(&names_arc), watch_tx).await?; + + Ok(()) +} + +async fn run_watcher( + db: Arc, + names: Arc>, + tx: broadcast::Sender, + session_wal: PathBuf, +) { + use std::collections::HashMap; + use std::time::Duration; + use crate::ipc::WatchEvent; + + let mut last_mtime = 0.0f64; + let mut last_ts: HashMap = HashMap::new(); + let mut initialized = false; + + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + + if tx.receiver_count() == 0 { + continue; + } + + let wal_mtime = match mtime_f64(&session_wal) { + Some(m) => m, + None => continue, + }; + if (wal_mtime - last_mtime).abs() < 0.001 { + continue; + } + last_mtime = wal_mtime; + + let path = match db.get("session/session.db").await { + Ok(Some(p)) => p, + _ => continue, + }; + + let path2 = path.clone(); + let rows: Vec<(String, Vec, i64, i64, String)> = match tokio::task::spawn_blocking(move || { + let conn = rusqlite::Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender + FROM SessionTable WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC LIMIT 50" + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1).unwrap_or_default(), + row.get::<_, i64>(2)?, + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, String>(4).unwrap_or_default(), + )) + })?.collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await { + Ok(Ok(r)) => r, + _ => continue, + }; + + let names_guard = match names.read() { + Ok(g) => g, + Err(_) => continue, + }; + + for (username, summary_bytes, ts, msg_type, sender) in &rows { + if !initialized { + last_ts.insert(username.clone(), *ts); + continue; + } + let prev_ts = last_ts.get(username).copied().unwrap_or(0); + if *ts <= prev_ts { + continue; + } + last_ts.insert(username.clone(), *ts); + + let display = names_guard.display(username); + let is_group = username.contains("@chatroom"); + let summary = decompress_or_str(summary_bytes); + let summary = if summary.contains(":\n") { + summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string() + } else { + summary + }; + let sender_display = if !sender.is_empty() { + names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone()) + } else { + String::new() + }; + + let event = WatchEvent { + event: "message".into(), + time: Some(fmt_hhmm(*ts)), + chat: Some(display), + username: Some(username.clone()), + is_group: Some(is_group), + sender: Some(sender_display), + content: Some(summary), + msg_type: Some(query::fmt_type(*msg_type)), + timestamp: Some(*ts), + }; + let _ = tx.send(event); + } + + if !initialized { + initialized = true; + } + } +} + +fn mtime_f64(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok()? + .modified().ok() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64()) +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { return String::new(); } + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { return s; } + } + String::from_utf8_lossy(data).into_owned() +} + +fn fmt_hhmm(ts: i64) -> String { + use chrono::{Local, TimeZone}; + Local.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%H:%M").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +/// 从 all_keys.json 提取 rel_key -> enc_key 映射 +/// +/// 兼容两种格式: +/// - `{ "rel/path.db": { "enc_key": "hex" } }`(Python 版原生格式) +/// - `{ "rel/path.db": "hex" }`(简化格式) +fn extract_keys(json: &serde_json::Value) -> HashMap { + let mut result = HashMap::new(); + if let Some(obj) = json.as_object() { + for (k, v) in obj { + if k.starts_with('_') { continue; } + let enc_key = if let Some(s) = v.as_str() { + s.to_string() + } else if let Some(obj2) = v.as_object() { + obj2.get("enc_key") + .and_then(|e| e.as_str()) + .unwrap_or_default() + .to_string() + } else { + continue; + }; + if !enc_key.is_empty() { + // 统一路径分隔符 + let rel = k.replace('\\', "/"); + result.insert(rel, enc_key); + } + } + } + result +} + +/// 设置信号处理(Unix: SIGTERM/SIGINT) +async fn setup_signal_handler() { + #[cfg(unix)] + tokio::spawn(async move { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("无法监听 SIGTERM"); + let mut int = signal(SignalKind::interrupt()).expect("无法监听 SIGINT"); + tokio::select! { + _ = term.recv() => {}, + _ = int.recv() => {}, + } + cleanup_and_exit(); + }); +} + +fn cleanup_and_exit() { + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(config::pid_path()); + std::process::exit(0); +} diff --git a/src/daemon/query.rs b/src/daemon/query.rs new file mode 100644 index 0000000..8793a28 --- /dev/null +++ b/src/daemon/query.rs @@ -0,0 +1,676 @@ +use anyhow::{Context, Result}; +use chrono::{Local, TimeZone}; +use regex::Regex; +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::collections::HashMap; + +use super::cache::DbCache; + +/// 联系人名称缓存 +#[derive(Clone)] +pub struct Names { + /// username -> display_name + pub map: HashMap, + /// md5(username) -> username(用于从 Msg_ 表名反推联系人) + pub md5_to_uname: HashMap, + /// 消息 DB 的相对路径列表(message/message_N.db) + pub msg_db_keys: Vec, +} + +impl Names { + pub fn display(&self, username: &str) -> String { + self.map.get(username).cloned().unwrap_or_else(|| username.to_string()) + } +} + +/// 加载联系人缓存(从 contact/contact.db) +pub async fn load_names(db: &DbCache) -> Result { + let path = db.get("contact/contact.db").await?; + let mut map = HashMap::new(); + if let Some(p) = path { + let p2 = p.clone(); + let rows: Vec<(String, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&p2).context("打开 contact.db 失败")?; + let mut stmt = conn.prepare( + "SELECT username, nick_name, remark FROM contact" + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await??; + + for (uname, nick, remark) in rows { + let display = if !remark.is_empty() { remark } + else if !nick.is_empty() { nick } + else { uname.clone() }; + map.insert(uname, display); + } + } + + let md5_to_uname: HashMap = map.keys() + .map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone())) + .collect(); + + Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new() }) +} + +/// 查询最近会话列表 +pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result { + let path = db.get("session/session.db").await? + .context("无法解密 session.db")?; + + let path2 = path.clone(); + let limit_val = limit; + let rows: Vec<(String, i64, Vec, i64, i64, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable + WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC LIMIT ?" + )?; + let rows = stmt.query_map([limit_val as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1).unwrap_or(0), + row.get::<_, Vec>(2).unwrap_or_default(), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, i64>(4).unwrap_or(0), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await??; + + let mut results = Vec::new(); + for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows { + let display = names.display(&username); + let is_group = username.contains("@chatroom"); + + // 尝试 zstd 解压 summary + let summary = decompress_or_str(&summary_bytes); + let summary = strip_group_prefix(&summary); + + let sender_display = if is_group && !sender.is_empty() { + names.map.get(&sender).cloned().unwrap_or_else(|| { + if !sender_name.is_empty() { sender_name.clone() } else { sender.clone() } + }) + } else { + String::new() + }; + + results.push(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "unread": unread, + "last_msg_type": fmt_type(msg_type), + "last_sender": sender_display, + "summary": summary, + "timestamp": ts, + "time": fmt_time(ts, "%m-%d %H:%M"), + })); + } + Ok(json!({ "sessions": results })) +} + +/// 查询聊天记录 +pub async fn q_history( + db: &DbCache, + names: &Names, + chat: &str, + limit: usize, + offset: usize, + since: Option, + until: Option, +) -> Result { + let username = resolve_username(chat, names) + .with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let is_group = username.contains("@chatroom"); + + let tables = find_msg_tables(db, names, &username).await?; + if tables.is_empty() { + return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) })); + } + + let mut all_msgs: Vec = Vec::new(); + for (db_path, table_name) in &tables { + let path = db_path.clone(); + let tname = table_name.clone(); + let uname = username.clone(); + let is_group2 = is_group; + let names_map = names.map.clone(); + let since2 = since; + let until2 = until; + let limit2 = limit; + let offset2 = offset; + + let msgs: Vec = tokio::task::spawn_blocking(move || { + query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0) + }).await??; + + all_msgs.extend(msgs); + } + + all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = all_msgs.into_iter().skip(offset).take(limit).collect(); + let mut paged = paged; + paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0)); + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "count": paged.len(), + "messages": paged, + })) +} + +/// 搜索消息 +pub async fn q_search( + db: &DbCache, + names: &Names, + keyword: &str, + chats: Option>, + limit: usize, + since: Option, + until: Option, +) -> Result { + let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname) + + if let Some(chat_names) = chats { + for chat_name in &chat_names { + if let Some(uname) = resolve_username(chat_name, names) { + let tables = find_msg_tables(db, names, &uname).await?; + for (p, t) in tables { + targets.push((p.to_string_lossy().into_owned(), t, names.display(&uname), uname.clone())); + } + } + } + } else { + // 全局搜索:遍历所有消息 DB + for rel_key in &names.msg_db_keys { + let path = match db.get(rel_key).await? { + Some(p) => p, + None => continue, + }; + let path2 = path.clone(); + let md5_lookup = names.md5_to_uname.clone(); + let names_map = names.map.clone(); + + let table_targets: Vec<(String, String, String, String)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + )?; + let table_names: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + let mut result = Vec::new(); + for tname in table_names { + if !re.is_match(&tname) { + continue; + } + let hash = &tname[4..]; + let uname = md5_lookup.get(hash).cloned().unwrap_or_default(); + let display = if uname.is_empty() { + String::new() + } else { + names_map.get(&uname).cloned().unwrap_or_else(|| uname.clone()) + }; + result.push(( + path2.to_string_lossy().into_owned(), + tname, + display, + uname, + )); + } + Ok::<_, anyhow::Error>(result) + }).await??; + + targets.extend(table_targets); + } + } + + // 按 db_path 分组 + let mut by_path: HashMap> = HashMap::new(); + for (p, t, d, u) in targets { + by_path.entry(p).or_default().push((t, d, u)); + } + + let mut results: Vec = Vec::new(); + let kw = keyword.to_string(); + for (db_path, table_list) in by_path { + let kw2 = kw.clone(); + let since2 = since; + let until2 = until; + let limit2 = limit * 3; + + let found: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&db_path)?; + let mut all = Vec::new(); + for (tname, display, uname) in &table_list { + let is_group = uname.contains("@chatroom"); + let rows = search_in_table(&conn, tname, &uname, is_group, + &HashMap::new(), &kw2, since2, until2, limit2)?; + for mut row in rows { + if row.get("chat").map(|v| v.as_str().unwrap_or("")).unwrap_or("").is_empty() { + if let Some(obj) = row.as_object_mut() { + obj.insert("chat".into(), serde_json::Value::String( + if display.is_empty() { tname.clone() } else { display.clone() } + )); + } + } + all.push(row); + } + } + Ok::<_, anyhow::Error>(all) + }).await??; + + results.extend(found); + } + + results.sort_by_key(|r| std::cmp::Reverse(r["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = results.into_iter().take(limit).collect(); + Ok(json!({ "keyword": keyword, "count": paged.len(), "results": paged })) +} + +/// 查询联系人 +pub async fn q_contacts(names: &Names, query: Option<&str>, limit: usize) -> Result { + let mut contacts: Vec = names.map.iter() + .filter(|(u, _)| !u.starts_with("gh_") && !u.starts_with("biz_")) + .map(|(u, d)| json!({ "username": u, "display": d })) + .collect(); + + if let Some(q) = query { + let low = q.to_lowercase(); + contacts.retain(|c| { + c["display"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false) + || c["username"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false) + }); + } + + contacts.sort_by(|a, b| { + a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or("")) + }); + + let total = contacts.len(); + contacts.truncate(limit); + Ok(json!({ "contacts": contacts, "total": total })) +} + +// ─── 内部辅助函数 ──────────────────────────────────────────────────────────── + +fn resolve_username(chat_name: &str, names: &Names) -> Option { + if names.map.contains_key(chat_name) + || chat_name.contains("@chatroom") + || chat_name.starts_with("wxid_") + { + return Some(chat_name.to_string()); + } + let low = chat_name.to_lowercase(); + // 精确匹配显示名 + for (uname, display) in &names.map { + if low == display.to_lowercase() { + return Some(uname.clone()); + } + } + // 模糊匹配 + for (uname, display) in &names.map { + if display.to_lowercase().contains(&low) { + return Some(uname.clone()); + } + } + None +} + +async fn find_msg_tables( + db: &DbCache, + names: &Names, + username: &str, +) -> Result> { + let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes())); + let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + if !re.is_match(&table_name) { + return Ok(Vec::new()); + } + + let mut results = Vec::new(); + for rel_key in &names.msg_db_keys { + let path = match db.get(rel_key).await? { + Some(p) => p, + None => continue, + }; + let tname = table_name.clone(); + let path2 = path.clone(); + let exists: Option = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + // 检查表是否存在 + let table_exists: Option = conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + [&tname], + |row| row.get(0), + ).ok().flatten(); + if table_exists.is_none() { + return Ok::<_, anyhow::Error>(None); + } + let max_ts: Option = conn.query_row( + &format!("SELECT MAX(create_time) FROM [{}]", tname), + [], + |row| row.get(0), + ).ok().flatten(); + Ok(max_ts) + }).await??; + + if exists.is_some() { + results.push((path.clone(), table_name.clone())); + } + } + + // 按最大时间戳排序(最新的优先) + Ok(results) +} + +fn query_messages( + db_path: &std::path::Path, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + since: Option, + until: Option, + limit: usize, + offset: usize, +) -> Result> { + let conn = Connection::open(db_path)?; + let id2u = load_id2u(&conn); + + let mut clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + if let Some(s) = since { + clauses.push("create_time >= ?"); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?"); + params.push(Box::new(u)); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ? OFFSET ?", + table, where_clause + ); + + params.push(Box::new(limit as i64)); + params.push(Box::new(offset as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Vec>(4).unwrap_or_default(), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map); + let text = fmt_content(local_id, local_type, &content, is_group); + + result.push(json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "sender": sender, + "content": text, + "type": fmt_type(local_type), + "local_id": local_id, + })); + } + Ok(result) +} + +fn search_in_table( + conn: &Connection, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + keyword: &str, + since: Option, + until: Option, + limit: usize, +) -> Result> { + let id2u = load_id2u(conn); + let mut clauses = vec!["message_content LIKE ?".to_string()]; + let mut params: Vec> = vec![Box::new(format!("%{}%", keyword))]; + if let Some(s) = since { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + let where_clause = format!("WHERE {}", clauses.join(" AND ")); + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ?", + table, where_clause + ); + params.push(Box::new(limit as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Vec>(4).unwrap_or_default(), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map); + let text = fmt_content(local_id, local_type, &content, is_group); + + result.push(json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "chat": "", + "sender": sender, + "content": text, + "type": fmt_type(local_type), + })); + } + Ok(result) +} + +fn load_id2u(conn: &Connection) -> HashMap { + let mut map = HashMap::new(); + if let Ok(mut stmt) = conn.prepare("SELECT rowid, user_name FROM Name2Id") { + let _ = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }).map(|rows| { + for r in rows.flatten() { + map.insert(r.0, r.1); + } + }); + } + map +} + +fn sender_label( + real_sender_id: i64, + content: &str, + is_group: bool, + chat_username: &str, + id2u: &HashMap, + names: &HashMap, +) -> String { + let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default(); + if is_group { + if !sender_uname.is_empty() && sender_uname != chat_username { + return names.get(&sender_uname).cloned().unwrap_or(sender_uname); + } + if content.contains(":\n") { + let raw = content.splitn(2, ":\n").next().unwrap_or(""); + return names.get(raw).cloned().unwrap_or_else(|| raw.to_string()); + } + return String::new(); + } + if !sender_uname.is_empty() && sender_uname != chat_username { + return names.get(&sender_uname).cloned().unwrap_or(sender_uname); + } + String::new() +} + +fn decompress_message(data: &[u8], ct: i64) -> String { + if ct == 4 && !data.is_empty() { + // zstd 压缩 + if let Ok(dec) = zstd::decode_all(data) { + return String::from_utf8_lossy(&dec).into_owned(); + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + // 尝试 zstd 解压 + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { + return s; + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn strip_group_prefix(s: &str) -> String { + if s.contains(":\n") { + s.splitn(2, ":\n").nth(1).unwrap_or(s).to_string() + } else { + s.to_string() + } +} + +pub fn fmt_type(t: i64) -> String { + let base = (t as u64 & 0xFFFFFFFF) as i64; + match base { + 1 => "文本".into(), + 3 => "图片".into(), + 34 => "语音".into(), + 42 => "名片".into(), + 43 => "视频".into(), + 47 => "表情".into(), + 48 => "位置".into(), + 49 => "链接/文件".into(), + 50 => "通话".into(), + 10000 => "系统".into(), + 10002 => "撤回".into(), + _ => format!("type={}", base), + } +} + +fn fmt_content(local_id: i64, local_type: i64, content: &str, is_group: bool) -> String { + let base = (local_type as u64 & 0xFFFFFFFF) as i64; + match base { + 3 => return format!("[图片] local_id={}", local_id), + 47 => return "[表情]".into(), + 50 => return "[通话]".into(), + _ => {} + } + + let text = if is_group && content.contains(":\n") { + content.splitn(2, ":\n").nth(1).unwrap_or(content) + } else { + content + }; + + if base == 49 && text.contains(" Option { + // 简单 XML 解析,避免引入重量级 XML 库(或直接用 minidom) + // 这里用基本字符串搜索实现 + let title = extract_xml_text(text, "title")?; + let atype = extract_xml_text(text, "type").unwrap_or_default(); + match atype.as_str() { + "6" => Some(if !title.is_empty() { format!("[文件] {}", title) } else { "[文件]".into() }), + "57" => { + let ref_content = extract_xml_text(text, "content") + .map(|s| { + let s: String = s.split_whitespace().collect::>().join(" "); + if s.len() > 80 { format!("{}...", &s[..80]) } else { s } + }) + .unwrap_or_default(); + let quote = if !title.is_empty() { format!("[引用] {}", title) } else { "[引用]".into() }; + if !ref_content.is_empty() { + Some(format!("{}\n \u{21b3} {}", quote, ref_content)) + } else { + Some(quote) + } + } + "33" | "36" | "44" => Some(if !title.is_empty() { format!("[小程序] {}", title) } else { "[小程序]".into() }), + _ => Some(if !title.is_empty() { format!("[链接] {}", title) } else { "[链接/文件]".into() }), + } +} + +fn extract_xml_text(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)?; + let content_start = start + open.len(); + let end = xml[content_start..].find(&close)?; + Some(xml[content_start..content_start + end].trim().to_string()) +} + +fn fmt_time(ts: i64, fmt: &str) -> String { + Local.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format(fmt).to_string()) + .unwrap_or_else(|| ts.to_string()) +} + diff --git a/src/daemon/server.rs b/src/daemon/server.rs new file mode 100644 index 0000000..9fa0e39 --- /dev/null +++ b/src/daemon/server.rs @@ -0,0 +1,219 @@ +use anyhow::Result; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::broadcast; + +use crate::ipc::{Request, Response, WatchEvent}; +use super::cache::DbCache; +use super::query::Names; + +/// 启动 IPC server(Unix socket / Windows named pipe) +pub async fn serve( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + #[cfg(unix)] + serve_unix(db, names, watch_tx).await?; + #[cfg(windows)] + serve_windows(db, names, watch_tx).await?; + Ok(()) +} + +#[cfg(unix)] +async fn serve_unix( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + use tokio::net::UnixListener; + let sock_path = crate::config::sock_path(); + + // 删除旧 socket 文件 + if sock_path.exists() { + let _ = tokio::fs::remove_file(&sock_path).await; + } + + let listener = UnixListener::bind(&sock_path)?; + // 设置权限 0600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?; + } + + eprintln!("[server] 监听 {}", sock_path.display()); + + loop { + let (stream, _) = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + let tx2 = watch_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection_unix(stream, db2, names2, tx2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +#[cfg(unix)] +async fn handle_connection_unix( + stream: tokio::net::UnixStream, + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + let line = match lines.next_line().await? { + Some(l) => l, + None => return Ok(()), + }; + + // 解析请求 + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(format!("JSON 解析错误: {}", e)); + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + return Ok(()); + } + }; + + match req { + Request::Watch => { + // 流式模式:持续推送事件 + let mut rx = watch_tx.subscribe(); + let connected = WatchEvent::connected(); + writer.write_all(connected.to_json_line()?.as_bytes()).await?; + + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(e) => { + if writer.write_all(e.to_json_line()?.as_bytes()).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + // 心跳 + let hb = WatchEvent::heartbeat(); + if writer.write_all(hb.to_json_line()?.as_bytes()).await.is_err() { + break; + } + } + } + } + } + other => { + let resp = dispatch(other, &db, &names).await; + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + } + } + Ok(()) +} + +#[cfg(windows)] +async fn serve_windows( + db: Arc, + names: Arc>, + watch_tx: broadcast::Sender, +) -> Result<()> { + use interprocess::local_socket::{ + tokio::prelude::*, GenericNamespaced, ListenerOptions, + }; + + let pipe_name = r"\\.\pipe\wechat-cli-daemon"; + let name = pipe_name.to_ns_name::()?; + let opts = ListenerOptions::new().name(name); + let listener = opts.create_tokio()?; + + eprintln!("[server] 监听 {}", pipe_name); + + loop { + let conn = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + let tx2 = watch_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection_generic(conn, db2, names2, tx2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +async fn dispatch( + req: Request, + db: &DbCache, + names: &std::sync::RwLock, +) -> Response { + use crate::ipc::Request::*; + use super::query; + + match req { + Ping => Response::ok(serde_json::json!({ "pong": true })), + Sessions { limit } => { + // 在 await 前获取并复制所需数据,避免 RwLockGuard 跨 await + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_sessions(db, &names_snapshot, limit).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + History { chat, limit, offset, since, until } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Search { keyword, chats, limit, since, until } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Contacts { query, limit } => { + let names_snapshot = match clone_names(names) { + Ok(n) => n, + Err(e) => return Response::err(e), + }; + match query::q_contacts(&names_snapshot, query.as_deref(), limit).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Watch => Response::err("Watch 命令不应通过 dispatch 处理"), + } +} + +/// 克隆 Names 以避免 RwLockGuard 跨 await +fn clone_names(names: &std::sync::RwLock) -> Result { + let guard = names.read().map_err(|_| "内部错误: names lock poisoned".to_string())?; + Ok(Names { + map: guard.map.clone(), + md5_to_uname: guard.md5_to_uname.clone(), + msg_db_keys: guard.msg_db_keys.clone(), + }) +} diff --git a/src/daemon/watcher.rs b/src/daemon/watcher.rs new file mode 100644 index 0000000..9b44857 --- /dev/null +++ b/src/daemon/watcher.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::broadcast; + +use super::cache::DbCache; +use super::query::{fmt_type, Names}; +use crate::ipc::WatchEvent; + +/// 启动 WAL 变化监听 task +/// +/// 每 500ms 检测 session.db-wal 的 mtime,有变化时重新读 session.db, +/// 找到 timestamp 更新的行,broadcast 到所有 watch 客户端 +#[allow(dead_code)] +pub async fn start_watcher( + db: &'static DbCache, + names_ref: &'static std::sync::RwLock, + tx: broadcast::Sender, + session_wal_path: PathBuf, +) { + tokio::spawn(async move { + let mut last_mtime = 0.0f64; + let mut last_ts: HashMap = HashMap::new(); + let mut initialized = false; + + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + + // 如果没有订阅者,跳过 + if tx.receiver_count() == 0 { + continue; + } + + let wal_mtime = match mtime_f64(&session_wal_path) { + Some(m) => m, + None => continue, + }; + + if (wal_mtime - last_mtime).abs() < 0.001 { + continue; + } + last_mtime = wal_mtime; + + // 重新解密 session.db + let path = match db.get("session/session.db").await { + Ok(Some(p)) => p, + _ => continue, + }; + + let path2 = path.clone(); + let rows: Vec<(String, Vec, i64, i64, String)> = match tokio::task::spawn_blocking(move || { + let conn = rusqlite::Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender + FROM SessionTable WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC LIMIT 50" + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1).unwrap_or_default(), + row.get::<_, i64>(2)?, + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, String>(4).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await { + Ok(Ok(r)) => r, + _ => continue, + }; + + let names_guard = names_ref.read().expect("names lock poisoned"); + + for (username, summary_bytes, ts, msg_type, sender) in &rows { + if !initialized { + last_ts.insert(username.clone(), *ts); + continue; + } + + let prev_ts = last_ts.get(username).copied().unwrap_or(0); + if *ts <= prev_ts { + continue; + } + last_ts.insert(username.clone(), *ts); + + let display = names_guard.display(username); + let is_group = username.contains("@chatroom"); + + let summary = decompress_or_str(summary_bytes); + let summary = if summary.contains(":\n") { + summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string() + } else { + summary + }; + + let sender_display = if !sender.is_empty() { + names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone()) + } else { + String::new() + }; + + let event = WatchEvent { + event: "message".into(), + time: Some(fmt_time_hhmm(*ts)), + chat: Some(display), + username: Some(username.clone()), + is_group: Some(is_group), + sender: Some(sender_display), + content: Some(summary), + msg_type: Some(fmt_type(*msg_type)), + timestamp: Some(*ts), + }; + + let _ = tx.send(event); + } + + if !initialized { + initialized = true; + } + } + }); +} + +fn mtime_f64(path: &std::path::Path) -> Option { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64()) +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { + return s; + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn fmt_time_hhmm(ts: i64) -> String { + use chrono::{Local, TimeZone}; + Local.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%H:%M").to_string()) + .unwrap_or_else(|| ts.to_string()) +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..c94e4aa --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// CLI 向 daemon 发送的请求(换行符分隔 JSON,与 Python 版兼容) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Request { + Ping, + Sessions { + #[serde(default = "default_limit_20")] + limit: usize, + }, + History { + chat: String, + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + }, + Search { + keyword: String, + #[serde(skip_serializing_if = "Option::is_none")] + chats: Option>, + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + }, + Contacts { + #[serde(skip_serializing_if = "Option::is_none")] + query: Option, + #[serde(default = "default_limit_50")] + limit: usize, + }, + Watch, +} + +impl Request { + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} + +/// daemon 的响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(flatten)] + pub data: Value, +} + +impl Response { + pub fn ok(data: Value) -> Self { + Self { ok: true, error: None, data } + } + + pub fn err(msg: impl Into) -> Self { + Self { ok: false, error: Some(msg.into()), data: Value::Null } + } + + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} + +fn default_limit_20() -> usize { 20 } +fn default_limit_50() -> usize { 50 } + +/// Watch 事件(daemon -> CLI 流式推送) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchEvent { + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub msg_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +impl WatchEvent { + pub fn connected() -> Self { + Self { + event: "connected".into(), + time: None, chat: None, username: None, is_group: None, + sender: None, content: None, msg_type: None, timestamp: None, + } + } + + pub fn heartbeat() -> Self { + Self { + event: "heartbeat".into(), + time: None, chat: None, username: None, is_group: None, + sender: None, content: None, msg_type: None, timestamp: None, + } + } + + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6c3f9a2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,14 @@ +mod config; +mod ipc; +mod crypto; +mod scanner; +mod daemon; +mod cli; + +fn main() { + if std::env::var("WX_DAEMON_MODE").is_ok() { + daemon::run(); + } else { + cli::run(); + } +} diff --git a/src/scanner/linux.rs b/src/scanner/linux.rs new file mode 100644 index 0000000..7b70727 --- /dev/null +++ b/src/scanner/linux.rs @@ -0,0 +1,187 @@ +/// Linux WeChat 进程内存密钥扫描器 +/// +/// 通过 /proc//maps 枚举内存区域, +/// 通过 /proc//mem 读取内存内容, +/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 +use anyhow::{bail, Context, Result}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use super::{collect_db_salts, KeyEntry}; + +const HEX_PATTERN_LEN: usize = 96; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 WeChat 进程 PID +fn find_wechat_pid() -> Option { + let proc_dir = std::fs::read_dir("/proc").ok()?; + for entry in proc_dir.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // 只处理数字目录(PID) + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let comm_path = format!("/proc/{}/comm", name_str); + if let Ok(comm) = std::fs::read_to_string(&comm_path) { + let comm = comm.trim().to_lowercase(); + if comm == "wechat" || comm == "weixin" { + if let Ok(pid) = name_str.parse::() { + return Some(pid); + } + } + } + } + None +} + +/// 解析 /proc//maps 文件,返回可读的内存区域 (start, end) +fn parse_maps(pid: u32) -> Result> { + let maps_path = format!("/proc/{}/maps", pid); + let content = std::fs::read_to_string(&maps_path) + .with_context(|| format!("读取 {} 失败", maps_path))?; + + let mut regions = Vec::new(); + for line in content.lines() { + // 格式: start-end perms offset dev inode pathname + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + if parts.len() < 2 { + continue; + } + let perms = parts[1].trim_start(); + // 只选取 r 和 w 权限的区域 + if !perms.starts_with("rw") { + continue; + } + let addr_parts: Vec<&str> = parts[0].splitn(2, '-').collect(); + if addr_parts.len() != 2 { + continue; + } + if let (Ok(start), Ok(end)) = ( + u64::from_str_radix(addr_parts[0], 16), + u64::from_str_radix(addr_parts[1], 16), + ) { + regions.push((start, end)); + } + } + Ok(regions) +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid() + .context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let regions = parse_maps(pid)?; + eprintln!("找到 {} 个可读写内存区域", regions.len()); + + let mem_path = format!("/proc/{}/mem", pid); + let mut mem_file = std::fs::File::open(&mem_path) + .with_context(|| format!("打开 {} 失败,请以 root 权限运行", mem_path))?; + + let mut raw_keys: Vec<(String, String)> = Vec::new(); + for (start, end) in ®ions { + scan_region(&mut mem_file, *start, *end, &mut raw_keys); + } + // 去重 + raw_keys.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1); + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +fn scan_region( + mem: &mut std::fs::File, + start: u64, + end: u64, + results: &mut Vec<(String, String)>, +) { + let total_len = (end - start) as usize; + let overlap = HEX_PATTERN_LEN + 3; + let mut offset = 0usize; + + loop { + if offset >= total_len { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, total_len - offset); + let addr = start + offset as u64; + + if mem.seek(SeekFrom::Start(addr)).is_err() { + break; + } + let mut buf = vec![0u8; chunk_size]; + match mem.read(&mut buf) { + Ok(n) if n > 0 => { + buf.truncate(n); + search_pattern(&buf, results); + } + _ => {} + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; + if buf.len() < total { + return; + } + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + if !all_hex { + i += 1; + continue; + } + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + i += total; + } +} diff --git a/src/scanner/macos.rs b/src/scanner/macos.rs new file mode 100644 index 0000000..a98cecd --- /dev/null +++ b/src/scanner/macos.rs @@ -0,0 +1,296 @@ +/// macOS WeChat 进程内存密钥扫描器 +/// +/// 翻译自 find_all_keys_macos.c,使用 Mach VM API: +/// - task_for_pid: 获取目标进程的 task port(需要 root 权限) +/// - mach_vm_region: 枚举内存区域 +/// - mach_vm_read: 读取内存块 +/// +/// 注意: +/// 1. 需要以 root (sudo) 运行 +/// 2. WeChat 需要进行 ad-hoc 签名 +/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 +use anyhow::{bail, Context, Result}; +use std::path::Path; + +use super::{collect_db_salts, KeyEntry}; + +// Mach 相关常量 +const KERN_SUCCESS: i32 = 0; +const VM_PROT_READ: i32 = 1; +const VM_PROT_WRITE: i32 = 2; +const VM_REGION_BASIC_INFO_64: i32 = 9; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; // 2MB +const HEX_PATTERN_LEN: usize = 96; // 64(key) + 32(salt) + +// vm_region_basic_info_64 结构体 +#[repr(C)] +struct VmRegionBasicInfo64 { + protection: i32, + max_protection: i32, + inheritance: u32, + shared: u32, + reserved: u32, + _offset: u64, + behavior: i32, + user_wired_count: u16, +} + +// Mach FFI 声明 +#[allow(non_camel_case_types)] +type kern_return_t = i32; +#[allow(non_camel_case_types)] +type mach_port_t = u32; +#[allow(non_camel_case_types)] +type mach_vm_address_t = u64; +#[allow(non_camel_case_types)] +type mach_vm_size_t = u64; +#[allow(non_camel_case_types)] +type mach_msg_type_number_t = u32; +#[allow(non_camel_case_types)] +type vm_offset_t = usize; +#[allow(non_camel_case_types, dead_code)] +type vm_prot_t = i32; + +extern "C" { + fn mach_task_self() -> mach_port_t; + fn task_for_pid(host: mach_port_t, pid: libc::pid_t, task: *mut mach_port_t) -> kern_return_t; + fn mach_vm_region( + task: mach_port_t, + address: *mut mach_vm_address_t, + size: *mut mach_vm_size_t, + flavor: i32, + info: *mut VmRegionBasicInfo64, + info_count: *mut mach_msg_type_number_t, + obj_name: *mut mach_port_t, + ) -> kern_return_t; + fn mach_vm_read( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + data: *mut vm_offset_t, + data_cnt: *mut mach_msg_type_number_t, + ) -> kern_return_t; + fn mach_vm_deallocate( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + ) -> kern_return_t; +} + +/// 查找 WeChat 进程的 PID +fn find_wechat_pid() -> Option { + // 使用 pgrep -x WeChat 查找(与 C 版本一致) + let output = std::process::Command::new("pgrep") + .args(["-x", "WeChat"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout); + s.trim().parse().ok() +} + +/// 判断字节是否是 ASCII 十六进制字符 +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + // 1. 查找 WeChat PID + let pid = find_wechat_pid() + .context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + + // 2. 获取 task port + // SAFETY: task_for_pid 是标准 Mach API,参数合法 + let task = unsafe { + let mut task: mach_port_t = 0; + let kr = task_for_pid(mach_task_self(), pid, &mut task); + if kr != KERN_SUCCESS { + bail!( + "task_for_pid 失败 (kr={})\n请确认:(1) 以 root 运行 (2) WeChat 已 ad-hoc 签名", + kr + ); + } + task + }; + eprintln!("Got task port: {}", task); + + // 3. 收集数据库 salt 映射 + eprintln!("扫描数据库文件..."); + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + // 4. 扫描进程内存 + eprintln!("扫描进程内存寻找密钥..."); + let raw_keys = scan_memory(task)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + // 5. 将密钥与数据库 salt 匹配 + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +/// 扫描进程内存,返回 (key_hex, salt_hex) 列表 +fn scan_memory(task: mach_port_t) -> Result> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: mach_vm_address_t = 0; + + // VM_REGION_BASIC_INFO_COUNT_64 = sizeof(vm_region_basic_info_64) / sizeof(int32_t) + let info_count_expected: mach_msg_type_number_t = + (std::mem::size_of::() / 4) as u32; + + loop { + let mut size: mach_vm_size_t = 0; + let mut info = VmRegionBasicInfo64 { + protection: 0, max_protection: 0, inheritance: 0, + shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0, + }; + let mut info_count: mach_msg_type_number_t = info_count_expected; + let mut obj_name: mach_port_t = 0; + + // SAFETY: mach_vm_region 枚举虚拟内存区域,所有参数合法 + let kr = unsafe { + mach_vm_region( + task, + &mut addr, + &mut size, + VM_REGION_BASIC_INFO_64, + &mut info, + &mut info_count, + &mut obj_name, + ) + }; + + if kr != KERN_SUCCESS { + break; + } + if size == 0 { + addr = addr.saturating_add(1); + continue; + } + + // 只扫描可读可写区域(密钥通常存在于堆内存) + if (info.protection & (VM_PROT_READ | VM_PROT_WRITE)) == (VM_PROT_READ | VM_PROT_WRITE) { + scan_region(task, addr, size, &mut results); + } + + addr = addr.saturating_add(size); + } + + // 去重 + results.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1); + Ok(results) +} + +/// 扫描单个内存区域,按 CHUNK_SIZE 分块读取 +fn scan_region( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + results: &mut Vec<(String, String)>, +) { + let end = addr + size; + let mut ca = addr; + + while ca < end { + let cs = std::cmp::min(end - ca, CHUNK_SIZE as u64); + + let mut data: vm_offset_t = 0; + let mut dc: mach_msg_type_number_t = 0; + + // SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区, + // 返回的 data 指针指向通过 vm_allocate 分配的内存, + // 必须用 mach_vm_deallocate 释放 + let kr = unsafe { + mach_vm_read(task, ca, cs, &mut data, &mut dc) + }; + + if kr == KERN_SUCCESS { + // SAFETY: data 是 mach_vm_read 返回的有效指针,dc 是字节数 + let buf: &[u8] = unsafe { + std::slice::from_raw_parts(data as *const u8, dc as usize) + }; + + search_pattern(buf, results); + + // SAFETY: 释放 mach_vm_read 分配的内核内存 + unsafe { + mach_vm_deallocate(mach_task_self(), data as u64, dc as u64); + } + } + + // 保留 (HEX_PATTERN_LEN + 3) 字节重叠以处理跨块边界的模式 + let overlap = HEX_PATTERN_LEN + 3; + if cs as usize > overlap { + ca += cs - overlap as u64; + } else { + ca += cs; + } + } +} + +/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式 +/// +/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节) +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + ' + if buf.len() < total { + return; + } + + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + + // 验证后续 96 字节都是十六进制字符 + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + + if !all_hex { + i += 1; + continue; + } + + // 验证结尾的单引号 + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + + // 提取 key_hex 和 salt_hex,统一转小写 + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + + // 去重检查 + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + + i += total; + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs new file mode 100644 index 0000000..abed70c --- /dev/null +++ b/src/scanner/mod.rs @@ -0,0 +1,84 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + +/// 扫描到的一条密钥记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyEntry { + /// 相对路径,如 "message/message_0.db" + pub db_name: String, + /// 32字节 AES 密钥(hex) + pub enc_key: String, + /// 16字节 salt(hex,来自数据库文件头) + pub salt: String, +} + +/// 从进程内存中扫描所有 SQLCipher 密钥 +/// +/// 需要以 root/Administrator 权限运行 +pub fn scan_keys(db_dir: &Path) -> Result> { + #[cfg(target_os = "macos")] + return macos::scan_keys(db_dir); + #[cfg(target_os = "linux")] + return linux::scan_keys(db_dir); + #[cfg(target_os = "windows")] + return windows::scan_keys(db_dir); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + anyhow::bail!("当前平台不支持自动密钥扫描") + } +} + +/// 读取 DB 文件前 16 字节作为 salt(hex),如果是明文 SQLite 则返回 None +pub fn read_db_salt(path: &Path) -> Option { + let mut buf = [0u8; 16]; + let mut f = std::fs::File::open(path).ok()?; + use std::io::Read; + f.read_exact(&mut buf).ok()?; + // 明文 SQLite:头部是 "SQLite format 3" + if &buf[..15] == b"SQLite format 3" { + return None; + } + Some(hex::encode(&buf)) +} + +/// 遍历 db_dir,收集所有 .db 文件的 salt -> 相对路径 映射 +pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)> { + let mut result = Vec::new(); + collect_recursive(db_dir, db_dir, &mut result); + result +} + +fn collect_recursive(base: &Path, dir: &Path, out: &mut Vec<(String, String)>) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_recursive(base, &path, out); + } else if path.extension().map(|e| e == "db").unwrap_or(false) { + if let Some(salt) = read_db_salt(&path) { + if let Ok(rel) = path.strip_prefix(base) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + out.push((salt, rel_str)); + } + } + } + } +} + +// hex encoding helper (avoid adding hex crate by implementing inline) +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } +} diff --git a/src/scanner/windows.rs b/src/scanner/windows.rs new file mode 100644 index 0000000..35e415f --- /dev/null +++ b/src/scanner/windows.rs @@ -0,0 +1,217 @@ +/// Windows WeChat 进程内存密钥扫描器 +/// +/// 使用 Windows API: +/// - CreateToolhelp32Snapshot + Process32Next: 枚举进程找 Weixin.exe +/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION) +/// - VirtualQueryEx: 枚举内存区域 +/// - ReadProcessMemory: 读取内存内容 +use anyhow::{bail, Context, Result}; +use std::path::Path; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_READWRITE, +}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, +}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; + +use super::{collect_db_salts, KeyEntry}; + +const HEX_PATTERN_LEN: usize = 96; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 Weixin.exe 进程 PID +fn find_wechat_pid() -> Option { + // SAFETY: CreateToolhelp32Snapshot 标准 Windows API + let snap = unsafe { + CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? + }; + + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + // SAFETY: Process32First/Process32Next 标准快照遍历 + unsafe { + if Process32First(snap, &mut entry).is_err() { + let _ = CloseHandle(snap); + return None; + } + loop { + let name = std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8) + .to_string_lossy(); + if name.eq_ignore_ascii_case("Weixin.exe") { + let pid = entry.th32ProcessID; + let _ = CloseHandle(snap); + return Some(pid); + } + if Process32Next(snap, &mut entry).is_err() { + break; + } + } + let _ = CloseHandle(snap); + } + None +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid() + .context("找不到 Weixin.exe 进程,请确认微信正在运行")?; + eprintln!("WeChat PID: {}", pid); + + // SAFETY: OpenProcess 请求读取权限 + let process = unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid) + .context("OpenProcess 失败,请以管理员权限运行")? + }; + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let raw_keys = scan_memory(process)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + // SAFETY: 关闭进程句柄 + unsafe { let _ = CloseHandle(process); } + + let mut entries = Vec::new(); + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + break; + } + } + } + eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len()); + Ok(entries) +} + +fn scan_memory(process: HANDLE) -> Result> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: usize = 0; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + // SAFETY: VirtualQueryEx 枚举进程内存区域 + let ret = unsafe { + VirtualQueryEx( + process, + Some(addr as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let region_size = mbi.RegionSize; + let base = mbi.BaseAddress as usize; + + // 只扫描已提交的可读写页面 + if mbi.State == MEM_COMMIT && mbi.Protect == PAGE_READWRITE { + scan_region(process, base, region_size, &mut results); + } + + addr = base.saturating_add(region_size); + if addr == 0 { + break; // overflow + } + } + + results.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1); + Ok(results) +} + +fn scan_region( + process: HANDLE, + base: usize, + size: usize, + results: &mut Vec<(String, String)>, +) { + let overlap = HEX_PATTERN_LEN + 3; + let mut offset = 0usize; + + loop { + if offset >= size { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read: usize = 0; + + // SAFETY: ReadProcessMemory 读取目标进程内存 + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ).is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + search_pattern(&buf, results); + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = HEX_PATTERN_LEN + 3; + if buf.len() < total { + return; + } + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| is_hex_char(c)); + if !all_hex { + i += 1; + continue; + } + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) + .to_lowercase(); + let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]) + .to_lowercase(); + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + i += total; + } +}