From c4664ab1c7b0cf1269cf0b125d151d75aa1b55e0 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 15 Dec 2025 02:30:43 -0300 Subject: [PATCH 001/182] feat(desktop): adiciona Raven Service e corrige UAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa Windows Service (raven-service) para operacoes privilegiadas - Comunicacao via Named Pipes sem necessidade de UAC adicional - Adiciona single-instance para evitar multiplos icones na bandeja - Corrige todos os warnings do clippy (rustdesk, lib, usb_control, agent) - Remove fallback de elevacao para evitar UAC desnecessario - USB Policy e RustDesk provisioning agora usam o servico quando disponivel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 4 +- apps/desktop/service/Cargo.lock | 1931 ++++++++++++++++++ apps/desktop/service/Cargo.toml | 70 + apps/desktop/service/src/ipc.rs | 290 +++ apps/desktop/service/src/main.rs | 268 +++ apps/desktop/service/src/rustdesk.rs | 846 ++++++++ apps/desktop/service/src/usb_policy.rs | 259 +++ apps/desktop/src-tauri/Cargo.lock | 17 + apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/installer-hooks.nsh | 79 +- apps/desktop/src-tauri/src/agent.rs | 6 +- apps/desktop/src-tauri/src/lib.rs | 84 +- apps/desktop/src-tauri/src/rustdesk.rs | 218 +- apps/desktop/src-tauri/src/service_client.rs | 244 +++ apps/desktop/src-tauri/src/usb_control.rs | 31 +- apps/desktop/src-tauri/tauri.conf.json | 3 + 16 files changed, 4209 insertions(+), 143 deletions(-) create mode 100644 apps/desktop/service/Cargo.lock create mode 100644 apps/desktop/service/Cargo.toml create mode 100644 apps/desktop/service/src/ipc.rs create mode 100644 apps/desktop/service/src/main.rs create mode 100644 apps/desktop/service/src/rustdesk.rs create mode 100644 apps/desktop/service/src/usb_policy.rs create mode 100644 apps/desktop/src-tauri/src/service_client.rs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 00e9106..cac2fb0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "node ./scripts/tauri-with-stub.mjs", - "gen:icon": "node ./scripts/build-icon.mjs" + "gen:icon": "node ./scripts/build-icon.mjs", + "build:service": "cd service && cargo build --release", + "build:all": "bun run build:service && bun run tauri build" }, "dependencies": { "@radix-ui/react-scroll-area": "^1.2.3", diff --git a/apps/desktop/service/Cargo.lock b/apps/desktop/service/Cargo.lock new file mode 100644 index 0000000..da860fc --- /dev/null +++ b/apps/desktop/service/Cargo.lock @@ -0,0 +1,1931 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[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 = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[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", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +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 = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raven-service" +version = "0.1.0" +dependencies = [ + "chrono", + "interprocess", + "once_cell", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "windows", + "windows-service", + "winreg", +] + +[[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 = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[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 = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[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-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + +[[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.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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.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 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/apps/desktop/service/Cargo.toml b/apps/desktop/service/Cargo.toml new file mode 100644 index 0000000..a1334d5 --- /dev/null +++ b/apps/desktop/service/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "raven-service" +version = "0.1.0" +description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop" +authors = ["Esdras Renan"] +edition = "2021" + +[[bin]] +name = "raven-service" +path = "src/main.rs" + +[dependencies] +# Windows Service +windows-service = "0.7" + +# Async runtime +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] } + +# IPC via Named Pipes +interprocess = { version = "2", features = ["tokio"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Windows Registry +winreg = "0.55" + +# Error handling +thiserror = "1.0" + +# HTTP client (para RustDesk) +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# Crypto (para RustDesk ID) +sha2 = "0.10" + +# UUID para request IDs +uuid = { version = "1", features = ["v4"] } + +# Parking lot para locks +parking_lot = "0.12" + +# Once cell para singletons +once_cell = "1.19" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_System_SystemServices", + "Win32_Storage_FileSystem", +] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/apps/desktop/service/src/ipc.rs b/apps/desktop/service/src/ipc.rs new file mode 100644 index 0000000..26091b6 --- /dev/null +++ b/apps/desktop/service/src/ipc.rs @@ -0,0 +1,290 @@ +//! Modulo IPC - Servidor de Named Pipes +//! +//! Implementa comunicacao entre o Raven UI e o Raven Service +//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado. + +use crate::{rustdesk, usb_policy}; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use thiserror::Error; +use tracing::{debug, info, warn}; + +#[derive(Debug, Error)] +pub enum IpcError { + #[error("Erro de IO: {0}")] + Io(#[from] std::io::Error), + + #[error("Erro de serializacao: {0}")] + Json(#[from] serde_json::Error), +} + +/// Requisicao JSON-RPC simplificada +#[derive(Debug, Deserialize)] +pub struct Request { + pub id: String, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// Resposta JSON-RPC simplificada +#[derive(Debug, Serialize)] +pub struct Response { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub code: i32, + pub message: String, +} + +impl Response { + pub fn success(id: String, result: serde_json::Value) -> Self { + Self { + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: String, code: i32, message: String) -> Self { + Self { + id, + result: None, + error: Some(ErrorResponse { code, message }), + } + } +} + +/// Inicia o servidor de Named Pipes +pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> { + info!("Iniciando servidor IPC em: {}", pipe_name); + + loop { + match accept_connection(pipe_name).await { + Ok(()) => { + debug!("Conexao processada com sucesso"); + } + Err(e) => { + warn!("Erro ao processar conexao: {}", e); + } + } + } +} + +/// Aceita uma conexao e processa requisicoes +async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> { + use windows::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows::Win32::Security::{ + InitializeSecurityDescriptor, SetSecurityDescriptorDacl, + PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, + }; + use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX; + use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, + PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, + }; + use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION; + use windows::core::PCWSTR; + + // Cria o named pipe com seguranca que permite acesso a todos os usuarios + let pipe_name_wide: Vec = pipe_name.encode_utf16().chain(std::iter::once(0)).collect(); + + // Cria security descriptor com DACL nulo (permite acesso a todos) + let mut sd = SECURITY_DESCRIPTOR::default(); + unsafe { + let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _); + let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION); + // DACL nulo = acesso irrestrito + let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false); + } + + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: &mut sd as *mut _ as *mut _, + bInheritHandle: false.into(), + }; + + let pipe_handle = unsafe { + CreateNamedPipeW( + PCWSTR::from_raw(pipe_name_wide.as_ptr()), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 4096, // out buffer + 4096, // in buffer + 0, // default timeout + Some(&sa), // seguranca permissiva + ) + }; + + // Verifica se o handle e valido + if pipe_handle == INVALID_HANDLE_VALUE { + return Err(IpcError::Io(std::io::Error::last_os_error())); + } + + // Aguarda conexao de um cliente + info!("Aguardando conexao de cliente..."); + let connect_result = unsafe { + ConnectNamedPipe(pipe_handle, None) + }; + + if let Err(e) = connect_result { + // ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado + // o que e aceitavel + let error_code = e.code().0 as u32; + if error_code != 535 { + warn!("Erro ao aguardar conexao: {:?}", e); + } + } + + info!("Cliente conectado"); + + // Processa requisicoes do cliente + let result = process_client(pipe_handle); + + // Desconecta o cliente + unsafe { + let _ = DisconnectNamedPipe(pipe_handle); + } + + result +} + +/// Processa requisicoes de um cliente conectado +fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> { + use std::os::windows::io::{FromRawHandle, RawHandle}; + use std::fs::File; + + // Cria File handle a partir do pipe + let raw_handle = pipe_handle.0 as RawHandle; + let file = unsafe { File::from_raw_handle(raw_handle) }; + + let reader = BufReader::new(file.try_clone()?); + let mut writer = file; + + // Le linhas (cada linha e uma requisicao JSON) + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + if e.kind() == std::io::ErrorKind::BrokenPipe { + info!("Cliente desconectou"); + break; + } + return Err(e.into()); + } + }; + + if line.is_empty() { + continue; + } + + debug!("Requisicao recebida: {}", line); + + // Parse da requisicao + let response = match serde_json::from_str::(&line) { + Ok(request) => handle_request(request), + Err(e) => Response::error( + "unknown".to_string(), + -32700, + format!("Parse error: {}", e), + ), + }; + + // Serializa e envia resposta + let response_json = serde_json::to_string(&response)?; + debug!("Resposta: {}", response_json); + + writeln!(writer, "{}", response_json)?; + writer.flush()?; + } + + // IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele + std::mem::forget(writer); + + Ok(()) +} + +/// Processa uma requisicao e retorna a resposta +fn handle_request(request: Request) -> Response { + info!("Processando metodo: {}", request.method); + + match request.method.as_str() { + "health_check" => handle_health_check(request.id), + "apply_usb_policy" => handle_apply_usb_policy(request.id, request.params), + "get_usb_policy" => handle_get_usb_policy(request.id), + "provision_rustdesk" => handle_provision_rustdesk(request.id, request.params), + "get_rustdesk_status" => handle_get_rustdesk_status(request.id), + _ => Response::error( + request.id, + -32601, + format!("Metodo nao encontrado: {}", request.method), + ), + } +} + +// ============================================================================= +// Handlers de Requisicoes +// ============================================================================= + +fn handle_health_check(id: String) -> Response { + Response::success( + id, + serde_json::json!({ + "status": "ok", + "service": "RavenService", + "version": env!("CARGO_PKG_VERSION"), + "timestamp": chrono::Utc::now().timestamp_millis() + }), + ) +} + +fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response { + let policy = match params.get("policy").and_then(|p| p.as_str()) { + Some(p) => p, + None => { + return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string()) + } + }; + + match usb_policy::apply_policy(policy) { + Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()), + Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)), + } +} + +fn handle_get_usb_policy(id: String) -> Response { + match usb_policy::get_current_policy() { + Ok(policy) => Response::success( + id, + serde_json::json!({ + "policy": policy + }), + ), + Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)), + } +} + +fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response { + let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from); + let password = params.get("password").and_then(|p| p.as_str()).map(String::from); + let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from); + + match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) { + Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()), + Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)), + } +} + +fn handle_get_rustdesk_status(id: String) -> Response { + match rustdesk::get_status() { + Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()), + Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)), + } +} diff --git a/apps/desktop/service/src/main.rs b/apps/desktop/service/src/main.rs new file mode 100644 index 0000000..208e22c --- /dev/null +++ b/apps/desktop/service/src/main.rs @@ -0,0 +1,268 @@ +//! Raven Service - Servico Windows para operacoes privilegiadas +//! +//! Este servico roda como LocalSystem e executa operacoes que requerem +//! privilegios de administrador, como: +//! - Aplicar politicas de USB +//! - Provisionar e configurar RustDesk +//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE +//! +//! O app Raven UI comunica com este servico via Named Pipes. + +mod ipc; +mod rustdesk; +mod usb_policy; + +use std::ffi::OsString; +use std::time::Duration; +use tracing::{error, info}; +use windows_service::{ + define_windows_service, + service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, + ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, + service_dispatcher, +}; + +const SERVICE_NAME: &str = "RavenService"; +const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service"; +const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)"; +const PIPE_NAME: &str = r"\\.\pipe\RavenService"; + +define_windows_service!(ffi_service_main, service_main); + +fn main() -> Result<(), Box> { + // Configura logging + init_logging(); + + // Verifica argumentos de linha de comando + let args: Vec = std::env::args().collect(); + + if args.len() > 1 { + match args[1].as_str() { + "install" => { + install_service()?; + return Ok(()); + } + "uninstall" => { + uninstall_service()?; + return Ok(()); + } + "run" => { + // Modo de teste: roda sem registrar como servico + info!("Executando em modo de teste (nao como servico)"); + run_standalone()?; + return Ok(()); + } + _ => {} + } + } + + // Inicia como servico Windows + info!("Iniciando Raven Service..."); + service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; + Ok(()) +} + +fn init_logging() { + use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + + // Tenta criar diretorio de logs + let log_dir = std::env::var("PROGRAMDATA") + .map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs")) + .unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs")); + + let _ = std::fs::create_dir_all(&log_dir); + + // Arquivo de log + let log_file = log_dir.join("service.log"); + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file) + .ok(); + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")); + + if let Some(file) = file { + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(file).with_ansi(false)) + .init(); + } else { + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer()) + .init(); + } +} + +fn service_main(arguments: Vec) { + if let Err(e) = run_service(arguments) { + error!("Erro ao executar servico: {}", e); + } +} + +fn run_service(_arguments: Vec) -> Result<(), Box> { + info!("Servico iniciando..."); + + // Canal para shutdown + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx))); + + // Registra handler de controle do servico + let shutdown_tx_clone = shutdown_tx.clone(); + let status_handle = service_control_handler::register(SERVICE_NAME, move |control| { + match control { + ServiceControl::Stop | ServiceControl::Shutdown => { + info!("Recebido comando de parada"); + if let Ok(mut guard) = shutdown_tx_clone.lock() { + if let Some(tx) = guard.take() { + let _ = tx.send(()); + } + } + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + })?; + + // Atualiza status para Running + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + info!("Servico em execucao, aguardando conexoes..."); + + // Cria runtime Tokio + let runtime = tokio::runtime::Runtime::new()?; + + // Executa servidor IPC + runtime.block_on(async { + tokio::select! { + result = ipc::run_server(PIPE_NAME) => { + if let Err(e) = result { + error!("Erro no servidor IPC: {}", e); + } + } + _ = async { + let _ = shutdown_rx.await; + } => { + info!("Shutdown solicitado"); + } + } + }); + + // Atualiza status para Stopped + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + info!("Servico parado"); + Ok(()) +} + +fn run_standalone() -> Result<(), Box> { + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + info!("Servidor IPC iniciando em modo standalone..."); + + tokio::select! { + result = ipc::run_server(PIPE_NAME) => { + if let Err(e) = result { + error!("Erro no servidor IPC: {}", e); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Ctrl+C recebido, encerrando..."); + } + } + }); + + Ok(()) +} + +fn install_service() -> Result<(), Box> { + use windows_service::{ + service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType}, + service_manager::{ServiceManager, ServiceManagerAccess}, + }; + + info!("Instalando servico..."); + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?; + + let exe_path = std::env::current_exe()?; + + let service_info = ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(SERVICE_DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::AutoStart, + error_control: ServiceErrorControl::Normal, + executable_path: exe_path, + launch_arguments: vec![], + dependencies: vec![], + account_name: None, // LocalSystem + account_password: None, + }; + + let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?; + + // Define descricao + service.set_description(SERVICE_DESCRIPTION)?; + + info!("Servico instalado com sucesso: {}", SERVICE_NAME); + println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME); + println!("Para iniciar: sc start {}", SERVICE_NAME); + + Ok(()) +} + +fn uninstall_service() -> Result<(), Box> { + use windows_service::{ + service::ServiceAccess, + service_manager::{ServiceManager, ServiceManagerAccess}, + }; + + info!("Desinstalando servico..."); + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + + let service = manager.open_service( + SERVICE_NAME, + ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS, + )?; + + // Tenta parar o servico primeiro + let status = service.query_status()?; + if status.current_state != ServiceState::Stopped { + info!("Parando servico..."); + let _ = service.stop(); + std::thread::sleep(Duration::from_secs(2)); + } + + // Remove o servico + service.delete()?; + + info!("Servico desinstalado com sucesso"); + println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME); + + Ok(()) +} diff --git a/apps/desktop/service/src/rustdesk.rs b/apps/desktop/service/src/rustdesk.rs new file mode 100644 index 0000000..0df60aa --- /dev/null +++ b/apps/desktop/service/src/rustdesk.rs @@ -0,0 +1,846 @@ +//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk +//! +//! Gerencia a instalacao, configuracao e provisionamento do RustDesk. +//! Como o servico roda como LocalSystem, nao precisa de elevacao. + +use chrono::Utc; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::env; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; +use thiserror::Error; +use tracing::{error, info, warn}; + +const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest"; +const USER_AGENT: &str = "RavenService/1.0"; +const SERVER_HOST: &str = "rust.rever.com.br"; +const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI="; +const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI> = Lazy::new(|| Mutex::new(())); + +#[derive(Debug, Error)] +pub enum RustdeskError { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + #[error("Release asset nao encontrado para Windows x86_64")] + AssetMissing, + + #[error("Falha ao executar comando {command}: status {status:?}")] + CommandFailed { command: String, status: Option }, + + #[error("Falha ao detectar ID do RustDesk")] + MissingId, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskResult { + pub id: String, + pub password: String, + pub installed_version: Option, + pub updated: bool, + pub last_provisioned_at: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskStatus { + pub installed: bool, + pub running: bool, + pub id: Option, + pub version: Option, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct ReleaseResponse { + tag_name: String, + assets: Vec, +} + +/// Provisiona o RustDesk +pub fn ensure_rustdesk( + config_string: Option<&str>, + password_override: Option<&str>, + machine_id: Option<&str>, +) -> Result { + let _guard = PROVISION_MUTEX.lock(); + info!("Iniciando provisionamento do RustDesk"); + + // Prepara ACLs dos diretorios de servico + if let Err(e) = ensure_service_profiles_writable() { + warn!("Aviso ao preparar ACL: {}", e); + } + + // Le ID existente antes de qualquer limpeza + let preserved_remote_id = read_remote_id_from_profiles(); + if let Some(ref id) = preserved_remote_id { + info!("ID existente preservado: {}", id); + } + + let exe_path = detect_executable_path(); + let (installed_version, freshly_installed) = ensure_installed(&exe_path)?; + + info!( + "RustDesk {}: {}", + if freshly_installed { "instalado" } else { "ja presente" }, + exe_path.display() + ); + + // Para processos existentes + let _ = stop_rustdesk_processes(); + + // Limpa perfis apenas se instalacao fresca + if freshly_installed { + let _ = purge_existing_rustdesk_profiles(); + } + + // Aplica configuracao + if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) { + if let Err(e) = run_with_args(&exe_path, &["--config", config]) { + warn!("Falha ao aplicar config inline: {}", e); + } + } else { + let config_path = write_config_files()?; + if let Err(e) = apply_config(&exe_path, &config_path) { + warn!("Falha ao aplicar config via CLI: {}", e); + } + } + + // Define senha + let password = password_override + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); + + if let Err(e) = set_password(&exe_path, &password) { + warn!("Falha ao definir senha: {}", e); + } else { + let _ = ensure_password_files(&password); + let _ = propagate_password_profile(); + } + + // Define ID customizado + let custom_id = if let Some(ref existing_id) = preserved_remote_id { + if !freshly_installed { + Some(existing_id.clone()) + } else { + define_custom_id(&exe_path, machine_id) + } + } else { + define_custom_id(&exe_path, machine_id) + }; + + // Inicia servico + if let Err(e) = ensure_service_running(&exe_path) { + warn!("Falha ao iniciar servico: {}", e); + } + + // Obtem ID final + let final_id = match query_id_with_retries(&exe_path, 5) { + Ok(id) => id, + Err(_) => { + read_remote_id_from_profiles() + .or_else(|| custom_id.clone()) + .ok_or(RustdeskError::MissingId)? + } + }; + + // Garante ID em todos os arquivos + ensure_remote_id_files(&final_id); + + let version = query_version(&exe_path).ok().or(installed_version); + let last_provisioned_at = Utc::now().timestamp_millis(); + + info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version); + + Ok(RustdeskResult { + id: final_id, + password, + installed_version: version, + updated: freshly_installed, + last_provisioned_at, + }) +} + +/// Retorna status do RustDesk +pub fn get_status() -> Result { + let exe_path = detect_executable_path(); + let installed = exe_path.exists(); + + let running = if installed { + query_service_state().map(|s| s == "running").unwrap_or(false) + } else { + false + }; + + let id = if installed { + query_id(&exe_path).ok().or_else(read_remote_id_from_profiles) + } else { + None + }; + + let version = if installed { + query_version(&exe_path).ok() + } else { + None + }; + + Ok(RustdeskStatus { + installed, + running, + id, + version, + }) +} + +// ============================================================================= +// Funcoes Auxiliares +// ============================================================================= + +fn detect_executable_path() -> PathBuf { + let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string()); + Path::new(&program_files).join("RustDesk").join("rustdesk.exe") +} + +fn ensure_installed(exe_path: &Path) -> Result<(Option, bool), RustdeskError> { + if exe_path.exists() { + return Ok((None, false)); + } + + let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string())) + .join(CACHE_DIR_NAME); + fs::create_dir_all(&cache_root)?; + + let (installer_path, version_tag) = download_latest_installer(&cache_root)?; + run_installer(&installer_path)?; + thread::sleep(Duration::from_secs(20)); + + Ok((Some(version_tag), true)) +} + +fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> { + let client = Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(60)) + .build()?; + + let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?; + + let asset = release + .assets + .iter() + .find(|a| a.name.ends_with("x86_64.exe")) + .ok_or(RustdeskError::AssetMissing)?; + + let target_path = cache_root.join(&asset.name); + if target_path.exists() { + return Ok((target_path, release.tag_name)); + } + + info!("Baixando RustDesk: {}", asset.name); + let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?; + let mut output = File::create(&target_path)?; + response.copy_to(&mut output)?; + + Ok((target_path, release.tag_name)) +} + +fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> { + let status = hidden_command(installer_path) + .arg("--silent-install") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --silent-install", installer_path.display()), + status: status.code(), + }); + } + Ok(()) +} + +fn program_data_config_dir() -> PathBuf { + PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string())) + .join("RustDesk") + .join("config") +} + +/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema +/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios +fn all_user_appdata_config_dirs() -> Vec { + let mut dirs = Vec::new(); + + // Enumera C:\Users\*\AppData\Roaming\RustDesk\config + let users_dir = Path::new("C:\\Users"); + if let Ok(entries) = fs::read_dir(users_dir) { + for entry in entries.flatten() { + let path = entry.path(); + // Ignora pastas de sistema + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" { + continue; + } + let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config"); + // Verifica se o diretorio pai existe (usuario real) + if path.join("AppData").join("Roaming").exists() { + dirs.push(rustdesk_config); + } + } + } + + // Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos) + if let Ok(appdata) = env::var("APPDATA") { + let path = Path::new(&appdata).join("RustDesk").join("config"); + if !dirs.contains(&path) { + dirs.push(path); + } + } + + dirs +} + +fn service_profile_dirs() -> Vec { + vec![ + PathBuf::from(LOCAL_SERVICE_CONFIG), + PathBuf::from(LOCAL_SYSTEM_CONFIG), + ] +} + +fn remote_id_directories() -> Vec { + let mut dirs = Vec::new(); + dirs.push(program_data_config_dir()); + dirs.extend(service_profile_dirs()); + dirs.extend(all_user_appdata_config_dirs()); + dirs +} + +fn write_config_files() -> Result { + let config_contents = format!( + r#"[options] +key = "{key}" +relay-server = "{host}" +custom-rendezvous-server = "{host}" +api-server = "https://{host}" +verification-method = "{verification}" +approve-mode = "{approve}" +"#, + host = SERVER_HOST, + key = SERVER_KEY, + verification = SECURITY_VERIFICATION_VALUE, + approve = SECURITY_APPROVE_MODE_VALUE, + ); + + let main_path = program_data_config_dir().join("RustDesk2.toml"); + write_file(&main_path, &config_contents)?; + + for service_dir in service_profile_dirs() { + let service_profile = service_dir.join("RustDesk2.toml"); + let _ = write_file(&service_profile, &config_contents); + } + + Ok(main_path) +} + +fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + file.write_all(contents.as_bytes()) +} + +fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> { + run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()]) +} + +fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> { + run_with_args(exe_path, &["--password", secret]) +} + +fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option { + let value = machine_id.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { None } else { Some(trimmed) } + })?; + + let custom_id = derive_numeric_id(value); + if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() { + info!("ID deterministico definido: {}", custom_id); + Some(custom_id) + } else { + None + } +} + +fn derive_numeric_id(machine_id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(machine_id.as_bytes()); + let hash = hasher.finalize(); + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&hash[..8]); + let value = u64::from_le_bytes(bytes); + let num = (value % 900_000_000) + 100_000_000; + format!("{:09}", num) +} + +fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> { + ensure_service_installed(exe_path)?; + let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]); + let _ = run_sc(&["start", SERVICE_NAME]); + remove_rustdesk_autorun_artifacts(); + Ok(()) +} + +fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> { + if run_sc(&["query", SERVICE_NAME]).is_ok() { + return Ok(()); + } + run_with_args(exe_path, &["--install-service"]) +} + +fn stop_rustdesk_processes() -> Result<(), RustdeskError> { + let _ = run_sc(&["stop", SERVICE_NAME]); + thread::sleep(Duration::from_secs(2)); + + let status = hidden_command("taskkill") + .args(["/F", "/T", "/IM", "rustdesk.exe"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + + if status.success() || matches!(status.code(), Some(128)) { + Ok(()) + } else { + Err(RustdeskError::CommandFailed { + command: "taskkill".into(), + status: status.code(), + }) + } +} + +fn purge_existing_rustdesk_profiles() -> Result<(), String> { + let files = [ + "RustDesk.toml", + "RustDesk_local.toml", + "RustDesk2.toml", + "password", + "passwd", + "passwd.txt", + ]; + + for dir in remote_id_directories() { + if !dir.exists() { + continue; + } + for name in files { + let path = dir.join(name); + if path.exists() { + let _ = fs::remove_file(&path); + } + } + } + Ok(()) +} + +fn ensure_password_files(secret: &str) -> Result<(), String> { + for dir in remote_id_directories() { + let password_path = dir.join("RustDesk.toml"); + let _ = write_toml_kv(&password_path, "password", secret); + + let local_path = dir.join("RustDesk_local.toml"); + let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE); + let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE); + } + Ok(()) +} + +fn propagate_password_profile() -> io::Result { + // Encontra um diretorio de usuario que tenha arquivos de config + let user_dirs = all_user_appdata_config_dirs(); + let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists()); + + let Some(src_dir) = src_dir else { + // Se nenhum usuario tem config, usa ProgramData como fonte + let pd = program_data_config_dir(); + if !pd.join("RustDesk.toml").exists() { + return Ok(false); + } + return propagate_from_dir(&pd); + }; + + propagate_from_dir(src_dir) +} + +fn propagate_from_dir(src_dir: &Path) -> io::Result { + let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"]; + let mut propagated = false; + + for filename in propagation_files { + let src_path = src_dir.join(filename); + if !src_path.exists() { + continue; + } + + for dest_root in remote_id_directories() { + if dest_root == src_dir { + continue; // Nao copiar para si mesmo + } + let target_path = dest_root.join(filename); + if copy_overwrite(&src_path, &target_path).is_ok() { + propagated = true; + } + } + } + + Ok(propagated) +} + +fn ensure_remote_id_files(id: &str) { + for dir in remote_id_directories() { + let path = dir.join("RustDesk_local.toml"); + let _ = write_remote_id_value(&path, id); + } +} + +fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let replacement = format!("remote_id = '{}'\n", id); + if let Ok(existing) = fs::read_to_string(path) { + let mut replaced = false; + let mut buffer = String::with_capacity(existing.len() + replacement.len()); + for line in existing.lines() { + if line.trim_start().starts_with("remote_id") { + buffer.push_str(&replacement); + replaced = true; + } else { + buffer.push_str(line); + buffer.push('\n'); + } + } + if !replaced { + buffer.push_str(&replacement); + } + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + file.write_all(buffer.as_bytes()) + } else { + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + file.write_all(replacement.as_bytes()) + } +} + +fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let sanitized = value.replace('\\', "\\\\").replace('"', "\\\""); + let replacement = format!("{key} = \"{sanitized}\"\n"); + let existing = fs::read_to_string(path).unwrap_or_default(); + let mut replaced = false; + let mut buffer = String::with_capacity(existing.len() + replacement.len()); + for line in existing.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) { + buffer.push_str(&replacement); + replaced = true; + } else { + buffer.push_str(line); + buffer.push('\n'); + } + } + if !replaced { + buffer.push_str(&replacement); + } + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + file.write_all(buffer.as_bytes()) +} + +fn read_remote_id_from_profiles() -> Option { + for dir in remote_id_directories() { + for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] { + if let Some(id) = read_remote_id_file(&candidate) { + if !id.is_empty() { + return Some(id); + } + } + } + } + None +} + +fn read_remote_id_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(value) = parse_assignment(line, "remote_id") { + return Some(value); + } + } + None +} + +fn parse_assignment(line: &str, key: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.starts_with(key) { + return None; + } + let (_, rhs) = trimmed.split_once('=')?; + let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"'); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result { + for attempt in 0..attempts { + match query_id(exe_path) { + Ok(value) if !value.trim().is_empty() => return Ok(value), + _ => {} + } + if attempt + 1 < attempts { + thread::sleep(Duration::from_millis(800)); + } + } + Err(RustdeskError::MissingId) +} + +fn query_id(exe_path: &Path) -> Result { + let output = hidden_command(exe_path).arg("--get-id").output()?; + if !output.status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --get-id", exe_path.display()), + status: output.status.code(), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + return Err(RustdeskError::MissingId); + } + Ok(stdout) +} + +fn query_version(exe_path: &Path) -> Result { + let output = hidden_command(exe_path).arg("--version").output()?; + if !output.status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --version", exe_path.display()), + status: output.status.code(), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn query_service_state() -> Option { + let output = hidden_command("sc") + .args(["query", SERVICE_NAME]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let lower = line.to_lowercase(); + if lower.contains("running") { + return Some("running".to_string()); + } + if lower.contains("stopped") { + return Some("stopped".to_string()); + } + } + None +} + +fn run_sc(args: &[&str]) -> Result<(), RustdeskError> { + let status = hidden_command("sc") + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("sc {}", args.join(" ")), + status: status.code(), + }); + } + Ok(()) +} + +fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> { + let status = hidden_command(exe_path) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} {}", exe_path.display(), args.join(" ")), + status: status.code(), + }); + } + Ok(()) +} + +fn remove_rustdesk_autorun_artifacts() { + // Remove atalhos de inicializacao automatica + let mut startup_paths: Vec = Vec::new(); + if let Ok(appdata) = env::var("APPDATA") { + startup_paths.push( + Path::new(&appdata) + .join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"), + ); + } + startup_paths.push(PathBuf::from( + r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk", + )); + + for path in startup_paths { + if path.exists() { + let _ = fs::remove_file(&path); + } + } + + // Remove entradas de registro + for hive in ["HKCU", "HKLM"] { + let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive); + let _ = hidden_command("reg") + .args(["delete", ®_path, "/v", "RustDesk", "/f"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +fn ensure_service_profiles_writable() -> Result<(), String> { + for dir in service_profile_dirs() { + if !can_write_dir(&dir) { + fix_profile_acl(&dir)?; + } + } + Ok(()) +} + +fn can_write_dir(dir: &Path) -> bool { + if fs::create_dir_all(dir).is_err() { + return false; + } + let probe = dir.join(".raven_acl_probe"); + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&probe) + { + Ok(mut file) => { + if file.write_all(b"ok").is_err() { + let _ = fs::remove_file(&probe); + return false; + } + let _ = fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +fn fix_profile_acl(target: &Path) -> Result<(), String> { + let target_str = target.display().to_string(); + + // Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente + let _ = hidden_command("takeown") + .args(["/F", &target_str, "/R", "/D", "Y"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let status = hidden_command("icacls") + .args([ + &target_str, + "/grant", + "*S-1-5-32-544:(OI)(CI)F", + "*S-1-5-19:(OI)(CI)F", + "*S-1-5-32-545:(OI)(CI)M", + "/T", + "/C", + "/Q", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("Erro ao executar icacls: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1))) + } +} + +fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + if dst.is_dir() { + fs::remove_dir_all(dst)?; + } else if dst.exists() { + fs::remove_file(dst)?; + } + fs::copy(src, dst)?; + Ok(()) +} + +fn hidden_command(program: impl AsRef) -> Command { + let mut cmd = Command::new(program); + cmd.creation_flags(CREATE_NO_WINDOW); + cmd +} diff --git a/apps/desktop/service/src/usb_policy.rs b/apps/desktop/service/src/usb_policy.rs new file mode 100644 index 0000000..ed8144d --- /dev/null +++ b/apps/desktop/service/src/usb_policy.rs @@ -0,0 +1,259 @@ +//! Modulo USB Policy - Controle de dispositivos USB +//! +//! Implementa o controle de armazenamento USB no Windows. +//! Como o servico roda como LocalSystem, nao precisa de elevacao. + +use serde::{Deserialize, Serialize}; +use std::io; +use thiserror::Error; +use tracing::{error, info, warn}; +use winreg::enums::*; +use winreg::RegKey; + +// GUID para Removable Storage Devices (Disk) +const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}"; + +// Chaves de registro +const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices"; +const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR"; +const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum UsbPolicy { + Allow, + BlockAll, + Readonly, +} + +impl UsbPolicy { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "ALLOW" => Some(Self::Allow), + "BLOCK_ALL" => Some(Self::BlockAll), + "READONLY" => Some(Self::Readonly), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Allow => "ALLOW", + Self::BlockAll => "BLOCK_ALL", + Self::Readonly => "READONLY", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsbPolicyResult { + pub success: bool, + pub policy: String, + pub error: Option, + pub applied_at: Option, +} + +#[derive(Error, Debug)] +pub enum UsbControlError { + #[error("Politica USB invalida: {0}")] + InvalidPolicy(String), + + #[error("Erro de registro do Windows: {0}")] + RegistryError(String), + + #[error("Permissao negada")] + PermissionDenied, + + #[error("Erro de I/O: {0}")] + Io(#[from] io::Error), +} + +/// Aplica uma politica de USB +pub fn apply_policy(policy_str: &str) -> Result { + let policy = UsbPolicy::from_str(policy_str) + .ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?; + + let now = chrono::Utc::now().timestamp_millis(); + + info!("Aplicando politica USB: {:?}", policy); + + // 1. Aplicar Removable Storage Policy + apply_removable_storage_policy(policy)?; + + // 2. Aplicar USBSTOR + apply_usbstor_policy(policy)?; + + // 3. Aplicar WriteProtect se necessario + if policy == UsbPolicy::Readonly { + apply_write_protect(true)?; + } else { + apply_write_protect(false)?; + } + + // 4. Atualizar Group Policy (opcional) + if let Err(e) = refresh_group_policy() { + warn!("Falha ao atualizar group policy: {}", e); + } + + info!("Politica USB aplicada com sucesso: {:?}", policy); + + Ok(UsbPolicyResult { + success: true, + policy: policy.as_str().to_string(), + error: None, + applied_at: Some(now), + }) +} + +/// Retorna a politica USB atual +pub fn get_current_policy() -> Result { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + // Verifica Removable Storage Policy primeiro + let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID); + + if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) { + let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0); + let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0); + + if deny_read == 1 && deny_write == 1 { + return Ok("BLOCK_ALL".to_string()); + } + + if deny_read == 0 && deny_write == 1 { + return Ok("READONLY".to_string()); + } + } + + // Verifica USBSTOR como fallback + if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) { + let start: u32 = key.get_value("Start").unwrap_or(3); + if start == 4 { + return Ok("BLOCK_ALL".to_string()); + } + } + + Ok("ALLOW".to_string()) +} + +fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID); + + match policy { + UsbPolicy::Allow => { + // Tenta remover as restricoes, se existirem + if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) { + let _ = key.delete_value("Deny_Read"); + let _ = key.delete_value("Deny_Write"); + let _ = key.delete_value("Deny_Execute"); + } + // Tenta remover a chave inteira se estiver vazia + let _ = hklm.delete_subkey(&full_path); + } + UsbPolicy::BlockAll => { + let (key, _) = hklm + .create_subkey(&full_path) + .map_err(map_winreg_error)?; + + key.set_value("Deny_Read", &1u32) + .map_err(map_winreg_error)?; + key.set_value("Deny_Write", &1u32) + .map_err(map_winreg_error)?; + key.set_value("Deny_Execute", &1u32) + .map_err(map_winreg_error)?; + } + UsbPolicy::Readonly => { + let (key, _) = hklm + .create_subkey(&full_path) + .map_err(map_winreg_error)?; + + // Permite leitura, bloqueia escrita + key.set_value("Deny_Read", &0u32) + .map_err(map_winreg_error)?; + key.set_value("Deny_Write", &1u32) + .map_err(map_winreg_error)?; + key.set_value("Deny_Execute", &0u32) + .map_err(map_winreg_error)?; + } + } + + Ok(()) +} + +fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + let key = hklm + .open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS) + .map_err(map_winreg_error)?; + + match policy { + UsbPolicy::Allow => { + // Start = 3 habilita o driver + key.set_value("Start", &3u32) + .map_err(map_winreg_error)?; + } + UsbPolicy::BlockAll => { + // Start = 4 desabilita o driver + key.set_value("Start", &4u32) + .map_err(map_winreg_error)?; + } + UsbPolicy::Readonly => { + // Readonly mantem driver ativo + key.set_value("Start", &3u32) + .map_err(map_winreg_error)?; + } + } + + Ok(()) +} + +fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + if enable { + let (key, _) = hklm + .create_subkey(STORAGE_POLICY_PATH) + .map_err(map_winreg_error)?; + + key.set_value("WriteProtect", &1u32) + .map_err(map_winreg_error)?; + } else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { + let _ = key.set_value("WriteProtect", &0u32); + } + + Ok(()) +} + +fn refresh_group_policy() -> Result<(), UsbControlError> { + use std::os::windows::process::CommandExt; + use std::process::Command; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let output = Command::new("gpupdate") + .args(["/target:computer", "/force"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(UsbControlError::Io)?; + + if !output.status.success() { + warn!( + "gpupdate retornou erro: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) +} + +fn map_winreg_error(error: io::Error) -> UsbControlError { + if let Some(code) = error.raw_os_error() { + if code == 5 { + return UsbControlError::PermissionDenied; + } + } + UsbControlError::RegistryError(error.to_string()) +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 86e04da..a3a293a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -80,10 +80,12 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "thiserror 1.0.69", "tokio", + "uuid", "winreg", ] @@ -4748,6 +4750,21 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index efa7052..944e0d3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-updater = "2.9.0" tauri-plugin-process = "2.3.0" tauri-plugin-notification = "2" tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } @@ -41,6 +42,7 @@ hostname = "0.4" base64 = "0.22" sha2 = "0.10" convex = "0.10.2" +uuid = { version = "1", features = ["v4"] } # SSE usa reqwest com stream, nao precisa de websocket [target.'cfg(windows)'.dependencies] diff --git a/apps/desktop/src-tauri/installer-hooks.nsh b/apps/desktop/src-tauri/installer-hooks.nsh index f6e32c6..a716376 100644 --- a/apps/desktop/src-tauri/installer-hooks.nsh +++ b/apps/desktop/src-tauri/installer-hooks.nsh @@ -1,20 +1,97 @@ ; Hooks customizadas do instalador NSIS (Tauri) ; -; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo. +; Objetivo: +; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo +; - Instalar o Raven Service para operacoes privilegiadas sem UAC ; ; Nota: o bundler do Tauri injeta estes macros no script principal do instalador. BrandingText " " !macro NSIS_HOOK_PREINSTALL + ; Para qualquer instancia anterior do servico antes de atualizar + DetailPrint "Parando servicos anteriores..." + + ; Para o servico + nsExec::ExecToLog 'sc stop RavenService' + + ; Aguarda o servico parar completamente (ate 10 segundos) + nsExec::ExecToLog 'powershell -Command "$$i=0; while((Get-Service RavenService -ErrorAction SilentlyContinue).Status -eq \"Running\" -and $$i -lt 10){Start-Sleep 1;$$i++}"' + + ; Forca encerramento de processos remanescentes + nsExec::ExecToLog 'taskkill /F /IM raven-service.exe' + nsExec::ExecToLog 'taskkill /F /IM appsdesktop.exe' + + ; Aguarda liberacao dos arquivos + Sleep 2000 !macroend !macro NSIS_HOOK_POSTINSTALL + ; ========================================================================= + ; Instala e inicia o Raven Service + ; ========================================================================= + + DetailPrint "Instalando Raven Service..." + + ; O servico ja esta em $INSTDIR (copiado como resource pelo Tauri) + ; Registra o servico Windows + nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install' + Pop $0 + + ${If} $0 != 0 + DetailPrint "Aviso: Falha ao registrar servico (codigo: $0)" + ; Tenta remover e reinstalar + nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall' + Sleep 500 + nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install' + Pop $0 + ${EndIf} + + ; Inicia o servico + DetailPrint "Iniciando Raven Service..." + nsExec::ExecToLog 'sc start RavenService' + Pop $0 + + ${If} $0 == 0 + DetailPrint "Raven Service iniciado com sucesso!" + ${Else} + DetailPrint "Aviso: Servico sera iniciado na proxima reinicializacao" + ${EndIf} + + ; ========================================================================= + ; Verifica se RustDesk esta instalado + ; Se nao estiver, o Raven Service instalara automaticamente no primeiro uso + ; ========================================================================= + + IfFileExists "$PROGRAMFILES\RustDesk\rustdesk.exe" rustdesk_found rustdesk_not_found + + rustdesk_not_found: + DetailPrint "RustDesk sera instalado automaticamente pelo Raven Service." + Goto rustdesk_done + + rustdesk_found: + DetailPrint "RustDesk ja esta instalado." + + rustdesk_done: !macroend !macro NSIS_HOOK_PREUNINSTALL + ; ========================================================================= + ; Para e remove o Raven Service + ; ========================================================================= + + DetailPrint "Parando Raven Service..." + nsExec::ExecToLog 'sc stop RavenService' + Sleep 1000 + + DetailPrint "Removendo Raven Service..." + nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall' + + ; Aguarda um pouco para garantir que o servico foi removido + Sleep 500 !macroend !macro NSIS_HOOK_POSTUNINSTALL + ; Nada adicional necessario !macroend diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index b663005..4fbb53d 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value { } fn decode_utf16_le_to_string(bytes: &[u8]) -> Option { - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return None; } let utf16: Vec = bytes @@ -1086,7 +1086,7 @@ pub fn collect_profile() -> Result { let system = collect_system(); let os_name = System::name() - .or_else(|| System::long_os_version()) + .or_else(System::long_os_version) .unwrap_or_else(|| "desconhecido".to_string()); let os_version = System::os_version(); let architecture = std::env::consts::ARCH.to_string(); @@ -1146,7 +1146,7 @@ async fn post_heartbeat( .into_owned(); let os = MachineOs { name: System::name() - .or_else(|| System::long_os_version()) + .or_else(System::long_os_version) .unwrap_or_else(|| "desconhecido".to_string()), version: System::os_version(), architecture: Some(std::env::consts::ARCH.to_string()), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ece1ae8..2b3d54b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod agent; mod chat; #[cfg(target_os = "windows")] mod rustdesk; +#[cfg(target_os = "windows")] +mod service_client; mod usb_control; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; @@ -68,21 +70,21 @@ pub fn log_agent(level: &str, message: &str) { #[macro_export] macro_rules! log_info { ($($arg:tt)*) => { - $crate::log_agent("INFO", &format!($($arg)*)) + $crate::log_agent("INFO", format!($($arg)*).as_str()) }; } #[macro_export] macro_rules! log_error { ($($arg:tt)*) => { - $crate::log_agent("ERROR", &format!($($arg)*)) + $crate::log_agent("ERROR", format!($($arg)*).as_str()) }; } #[macro_export] macro_rules! log_warn { ($($arg:tt)*) => { - $crate::log_agent("WARN", &format!($($arg)*)) + $crate::log_agent("WARN", format!($($arg)*).as_str()) }; } @@ -189,6 +191,32 @@ fn run_rustdesk_ensure( password: Option, machine_id: Option, ) -> Result { + // Tenta usar o servico primeiro (sem UAC) + if service_client::is_service_available() { + log_info!("Usando Raven Service para provisionar RustDesk"); + match service_client::provision_rustdesk( + config_string.as_deref(), + password.as_deref(), + machine_id.as_deref(), + ) { + Ok(result) => { + return Ok(RustdeskProvisioningResult { + id: result.id, + password: result.password, + installed_version: result.installed_version, + updated: result.updated, + last_provisioned_at: result.last_provisioned_at, + }); + } + Err(e) => { + log_warn!("Falha ao usar servico para RustDesk: {e}"); + // Continua para fallback + } + } + } + + // Fallback: chamada direta (pode pedir UAC) + log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)"); rustdesk::ensure_rustdesk( config_string.as_deref(), password.as_deref(), @@ -208,14 +236,50 @@ fn run_rustdesk_ensure( #[tauri::command] fn apply_usb_policy(policy: String) -> Result { - let policy_enum = UsbPolicy::from_str(&policy) + // Valida a politica primeiro + let _policy_enum = UsbPolicy::from_str(&policy) .ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?; - usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string()) + // Tenta usar o servico primeiro (sem UAC) + #[cfg(target_os = "windows")] + if service_client::is_service_available() { + log_info!("Usando Raven Service para aplicar politica USB: {}", policy); + match service_client::apply_usb_policy(&policy) { + Ok(result) => { + return Ok(UsbPolicyResult { + success: result.success, + policy: result.policy, + error: result.error, + applied_at: result.applied_at, + }); + } + Err(e) => { + log_warn!("Falha ao usar servico para USB policy: {e}"); + // Continua para fallback + } + } + } + + // Fallback: chamada direta (pode pedir UAC) + log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)"); + usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string()) } #[tauri::command] fn get_usb_policy() -> Result { + // Tenta usar o servico primeiro + #[cfg(target_os = "windows")] + if service_client::is_service_available() { + match service_client::get_usb_policy() { + Ok(policy) => return Ok(policy), + Err(e) => { + log_warn!("Falha ao obter USB policy via servico: {e}"); + // Continua para fallback + } + } + } + + // Fallback: leitura direta (nao precisa elevacao para ler) usb_control::get_current_policy() .map(|p| p.as_str().to_string()) .map_err(|e| e.to_string()) @@ -452,6 +516,14 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + // Quando uma segunda instância tenta iniciar, foca a janela existente + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + })) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -481,7 +553,7 @@ pub fn run() { { let start_in_background = std::env::args().any(|arg| arg == "--background"); setup_raven_autostart(); - setup_tray(&app.handle())?; + setup_tray(app.handle())?; if start_in_background { if let Some(win) = app.get_webview_window("main") { let _ = win.hide(); diff --git a/apps/desktop/src-tauri/src/rustdesk.rs b/apps/desktop/src-tauri/src/rustdesk.rs index ef4a81f..8c6cee4 100644 --- a/apps/desktop/src-tauri/src/rustdesk.rs +++ b/apps/desktop/src-tauri/src/rustdesk.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use crate::RustdeskProvisioningResult; use chrono::{Local, Utc}; use once_cell::sync::Lazy; @@ -30,7 +28,9 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\ const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config"; const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados"; const MACHINE_STORE_FILENAME: &str = "machine-agent.json"; +#[allow(dead_code)] const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag"; +#[allow(dead_code)] const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt"; const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password"; const SECURITY_APPROVE_MODE_VALUE: &str = "password"; @@ -85,11 +85,11 @@ fn define_custom_id_from_machine(exe_path: &Path, machine_id: Option<&str>) -> O }) { match set_custom_id(exe_path, value) { Ok(custom) => { - log_event(&format!("ID determinístico definido: {custom}")); + log_event(format!("ID determinístico definido: {custom}")); Some(custom) } Err(error) => { - log_event(&format!("Falha ao definir ID determinístico: {error}")); + log_event(format!("Falha ao definir ID determinístico: {error}")); None } } @@ -107,7 +107,7 @@ pub fn ensure_rustdesk( log_event("Iniciando preparo do RustDesk"); if let Err(error) = ensure_service_profiles_writable_preflight() { - log_event(&format!( + log_event(format!( "Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha." )); } @@ -116,7 +116,7 @@ pub fn ensure_rustdesk( // Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece let preserved_remote_id = read_remote_id_from_profiles(); if let Some(ref id) = preserved_remote_id { - log_event(&format!("ID existente preservado antes da limpeza: {}", id)); + log_event(format!("ID existente preservado antes da limpeza: {}", id)); } let exe_path = detect_executable_path(); @@ -129,7 +129,7 @@ pub fn ensure_rustdesk( match stop_rustdesk_processes() { Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"), - Err(error) => log_event(&format!( + Err(error) => log_event(format!( "Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})" )), } @@ -139,7 +139,7 @@ pub fn ensure_rustdesk( if freshly_installed { match purge_existing_rustdesk_profiles() { Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"), - Err(error) => log_event(&format!( + Err(error) => log_event(format!( "Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})" )), } @@ -152,19 +152,19 @@ pub fn ensure_rustdesk( if trimmed.is_empty() { None } else { Some(trimmed) } }) { if let Err(error) = run_with_args(&exe_path, &["--config", value]) { - log_event(&format!("Falha ao aplicar configuração inline: {error}")); + log_event(format!("Falha ao aplicar configuração inline: {error}")); } else { log_event("Configuração aplicada via --config"); } } else { let config_path = write_config_files()?; - log_event(&format!( + log_event(format!( "Arquivo de configuração atualizado em {}", config_path.display() )); if let Err(error) = apply_config(&exe_path, &config_path) { - log_event(&format!("Falha ao aplicar configuração via CLI: {error}")); + log_event(format!("Falha ao aplicar configuração via CLI: {error}")); } else { log_event("Configuração aplicada via CLI"); } @@ -176,7 +176,7 @@ pub fn ensure_rustdesk( .unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); if let Err(error) = set_password(&exe_path, &password) { - log_event(&format!("Falha ao definir senha padrão: {error}")); + log_event(format!("Falha ao definir senha padrão: {error}")); } else { log_event("Senha padrão definida com sucesso"); log_event("Aplicando senha nos perfis do RustDesk"); @@ -185,21 +185,21 @@ pub fn ensure_rustdesk( log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk"); log_password_replication(&password); } - Err(error) => log_event(&format!("Falha ao persistir senha nos perfis: {error}")), + Err(error) => log_event(format!("Falha ao persistir senha nos perfis: {error}")), } match propagate_password_profile() { Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"), - Err(error) => log_event(&format!("Falha ao copiar perfil de senha: {error}")), + Err(error) => log_event(format!("Falha ao copiar perfil de senha: {error}")), } match replicate_password_artifacts() { Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"), - Err(error) => log_event(&format!("Falha ao replicar artefatos de senha: {error}")), + Err(error) => log_event(format!("Falha ao replicar artefatos de senha: {error}")), } if let Err(error) = enforce_security_flags() { - log_event(&format!("Falha ao reforçar configuração de senha permanente: {error}")); + log_event(format!("Falha ao reforçar configuração de senha permanente: {error}")); } } @@ -207,7 +207,7 @@ pub fn ensure_rustdesk( // Isso garante que reinstalar o Raven nao muda o ID do RustDesk let custom_id = if let Some(ref existing_id) = preserved_remote_id { if !freshly_installed { - log_event(&format!("Reutilizando ID existente do RustDesk: {}", existing_id)); + log_event(format!("Reutilizando ID existente do RustDesk: {}", existing_id)); Some(existing_id.clone()) } else { // Instalacao fresca - define novo ID baseado no machine_id @@ -219,7 +219,7 @@ pub fn ensure_rustdesk( }; if let Err(error) = ensure_service_running(&exe_path) { - log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}")); + log_event(format!("Falha ao reiniciar serviço do RustDesk: {error}")); } else { log_event("Serviço RustDesk reiniciado/run ativo"); } @@ -227,10 +227,10 @@ pub fn ensure_rustdesk( let reported_id = match query_id_with_retries(&exe_path, 5) { Ok(value) => value, Err(error) => { - log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}")); + log_event(format!("Falha ao obter ID após múltiplas tentativas: {error}")); match read_remote_id_from_profiles().or_else(|| custom_id.clone()) { Some(value) => { - log_event(&format!("ID obtido via arquivos de perfil: {value}")); + log_event(format!("ID obtido via arquivos de perfil: {value}")); value } None => return Err(error), @@ -242,7 +242,7 @@ pub fn ensure_rustdesk( if let Some(expected) = custom_id.as_ref() { if expected != &reported_id { - log_event(&format!( + log_event(format!( "ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico" )); @@ -252,25 +252,25 @@ pub fn ensure_rustdesk( Ok(_) => match query_id_with_retries(&exe_path, 3) { Ok(rechecked) => { if &rechecked == expected { - log_event(&format!("ID determinístico aplicado com sucesso: {rechecked}")); + log_event(format!("ID determinístico aplicado com sucesso: {rechecked}")); final_id = rechecked; enforced = true; } else { - log_event(&format!( + log_event(format!( "ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado" )); final_id = rechecked; } } Err(error) => { - log_event(&format!( + log_event(format!( "Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})" )); final_id = reported_id.clone(); } }, Err(error) => { - log_event(&format!( + log_event(format!( "Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})" )); final_id = reported_id.clone(); @@ -308,7 +308,7 @@ pub fn ensure_rustdesk( "lastError": serde_json::Value::Null }); if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) { - log_event(&format!("Aviso: falha ao salvar dados do RustDesk no store: {error}")); + log_event(format!("Aviso: falha ao salvar dados do RustDesk no store: {error}")); } else { log_event("Dados do RustDesk salvos no machine-agent.json"); } @@ -316,7 +316,7 @@ pub fn ensure_rustdesk( // Sincroniza com o backend imediatamente apos provisionar // O Rust faz o HTTP direto, sem passar pelo CSP do webview if let Err(error) = sync_remote_access_with_backend(&result) { - log_event(&format!("Aviso: falha ao sincronizar com backend: {error}")); + log_event(format!("Aviso: falha ao sincronizar com backend: {error}")); } else { log_event("Acesso remoto sincronizado com backend"); // Atualiza lastSyncedAt no store @@ -330,13 +330,13 @@ pub fn ensure_rustdesk( "lastError": serde_json::Value::Null }); if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) { - log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}")); + log_event(format!("Aviso: falha ao atualizar lastSyncedAt: {e}")); } else { log_event("lastSyncedAt atualizado com sucesso"); } } - log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version)); + log_event(format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version)); Ok(result) } @@ -403,7 +403,7 @@ fn write_config_files() -> Result { let config_contents = build_config_contents(); let main_path = program_data_config_dir().join("RustDesk2.toml"); write_file(&main_path, &config_contents)?; - log_event(&format!( + log_event(format!( "Config principal gravada em {}", main_path.display() )); @@ -412,7 +412,7 @@ fn write_config_files() -> Result { for service_dir in service_profile_dirs() { let service_profile = service_dir.join("RustDesk2.toml"); if let Err(error) = write_file(&service_profile, &config_contents) { - log_event(&format!( + log_event(format!( "Falha ao gravar config no perfil do serviço ({}): {error}", service_profile.display() )); @@ -421,7 +421,7 @@ fn write_config_files() -> Result { if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") { if let Err(error) = write_file(&appdata_path, &config_contents) { - log_event(&format!( + log_event(format!( "Falha ao atualizar config no AppData do usuário: {error}" )); } @@ -516,7 +516,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> { ensure_service_installed(exe_path)?; if let Err(error) = configure_service_startup() { - log_event(&format!( + log_event(format!( "Aviso: não foi possível reforçar autostart/recuperação do serviço RustDesk: {error}" )); } @@ -553,7 +553,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> { let _ = run_with_args(exe_path, &["--install-service"]); let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]); if let Err(error) = start_sequence() { - log_event(&format!( + log_event(format!( "Falha ao subir o serviço RustDesk mesmo após reinstalação: {error}" )); } @@ -631,8 +631,8 @@ fn remove_rustdesk_autorun_artifacts() { for path in startup_paths { if path.exists() { match fs::remove_file(&path) { - Ok(_) => log_event(&format!("Atalho de inicialização do RustDesk removido: {}", path.display())), - Err(error) => log_event(&format!( + Ok(_) => log_event(format!("Atalho de inicialização do RustDesk removido: {}", path.display())), + Err(error) => log_event(format!( "Falha ao remover atalho de inicialização do RustDesk ({}): {}", path.display(), error @@ -650,7 +650,7 @@ fn remove_rustdesk_autorun_artifacts() { .status(); if let Ok(code) = status { if code.success() { - log_event(&format!("Entrada de auto-run RustDesk removida de {}", reg_path)); + log_event(format!("Entrada de auto-run RustDesk removida de {}", reg_path)); } } } @@ -658,7 +658,7 @@ fn remove_rustdesk_autorun_artifacts() { fn stop_rustdesk_processes() -> Result<(), RustdeskError> { if let Err(error) = try_stop_service() { - log_event(&format!( + log_event(format!( "Não foi possível parar o serviço RustDesk antes da sincronização: {error}" )); } @@ -774,12 +774,12 @@ fn ensure_remote_id_files(id: &str) { for dir in remote_id_directories() { let path = dir.join("RustDesk_local.toml"); match write_remote_id_value(&path, id) { - Ok(_) => log_event(&format!( + Ok(_) => log_event(format!( "remote_id atualizado para {} em {}", id, path.display() )), - Err(error) => log_event(&format!( + Err(error) => log_event(format!( "Falha ao atualizar remote_id em {}: {error}", path.display() )), @@ -821,7 +821,7 @@ fn ensure_password_files(secret: &str) -> Result<(), String> { if let Err(error) = write_toml_kv(&password_path, "password", secret) { errors.push(format!("{} -> {}", password_path.display(), error)); } else { - log_event(&format!( + log_event(format!( "Senha escrita via fallback em {}", password_path.display() )); @@ -829,12 +829,12 @@ fn ensure_password_files(secret: &str) -> Result<(), String> { let local_path = dir.join("RustDesk_local.toml"); if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) { - log_event(&format!( + log_event(format!( "Falha ao ajustar verification-method em {}: {error}", local_path.display() )); } else { - log_event(&format!( + log_event(format!( "verification-method atualizado para {} em {}", SECURITY_VERIFICATION_VALUE, local_path.display() @@ -843,19 +843,19 @@ fn ensure_password_files(secret: &str) -> Result<(), String> { let rustdesk2_path = dir.join("RustDesk2.toml"); if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) { - log_event(&format!( + log_event(format!( "Falha ao ajustar flags no RustDesk2.toml em {}: {error}", rustdesk2_path.display() )); } if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) { - log_event(&format!( + log_event(format!( "Falha ao ajustar approve-mode em {}: {error}", local_path.display() )); } else { - log_event(&format!( + log_event(format!( "approve-mode atualizado para {} em {}", SECURITY_APPROVE_MODE_VALUE, local_path.display() @@ -877,7 +877,7 @@ fn enforce_security_flags() -> Result<(), String> { if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) { errors.push(format!("{} -> {}", local_path.display(), error)); } else { - log_event(&format!( + log_event(format!( "verification-method atualizado para {} em {}", SECURITY_VERIFICATION_VALUE, local_path.display() @@ -887,7 +887,7 @@ fn enforce_security_flags() -> Result<(), String> { if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) { errors.push(format!("{} -> {}", local_path.display(), error)); } else { - log_event(&format!( + log_event(format!( "approve-mode atualizado para {} em {}", SECURITY_APPROVE_MODE_VALUE, local_path.display() @@ -921,7 +921,7 @@ fn propagate_password_profile() -> io::Result { if !src_path.exists() { continue; } - log_event(&format!( + log_event(format!( "Copiando {} para ProgramData/serviços", src_path.display() )); @@ -929,7 +929,7 @@ fn propagate_password_profile() -> io::Result { for dest_root in propagation_destinations() { let target_path = dest_root.join(filename); copy_overwrite(&src_path, &target_path)?; - log_event(&format!( + log_event(format!( "{} propagado para {}", filename, target_path.display() @@ -969,7 +969,7 @@ fn replicate_password_artifacts() -> io::Result<()> { let target_path = dest.join(name); copy_overwrite(&source_path, &target_path)?; - log_event(&format!( + log_event(format!( "Artefato de senha {name} replicado para {}", target_path.display() )); @@ -981,13 +981,11 @@ fn replicate_password_artifacts() -> io::Result<()> { fn purge_existing_rustdesk_profiles() -> Result<(), String> { let mut errors = Vec::new(); - let mut cleaned_any = false; for dir in remote_id_directories() { match purge_config_dir(&dir) { Ok(true) => { - cleaned_any = true; - log_event(&format!( + log_event(format!( "Perfis antigos removidos em {}", dir.display() )); @@ -997,9 +995,7 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> { } } - if cleaned_any { - Ok(()) - } else if errors.is_empty() { + if errors.is_empty() { Ok(()) } else { Err(errors.join(" | ")) @@ -1030,6 +1026,7 @@ fn purge_config_dir(dir: &Path) -> Result { Ok(removed) } +#[allow(dead_code)] fn run_powershell_elevated(script: &str) -> Result<(), String> { let temp_dir = env::temp_dir(); let payload = temp_dir.join("raven_payload.ps1"); @@ -1077,6 +1074,7 @@ exit $process.ExitCode Err(format!("elevated ps exit {:?}", status.code())) } +#[allow(dead_code)] fn fix_profile_acl(target: &Path) -> Result<(), String> { let target_str = target.display().to_string(); let transcript = env::temp_dir().join("raven_acl_ps.log"); @@ -1111,7 +1109,7 @@ try {{ let result = run_powershell_elevated(&script); if result.is_err() { if let Ok(content) = fs::read_to_string(&transcript) { - log_event(&format!( + log_event(format!( "ACL transcript para {}:\n{}", target.display(), content )); @@ -1122,6 +1120,9 @@ try {{ } fn ensure_service_profiles_writable_preflight() -> Result<(), String> { + // Verificamos se os diretorios de perfil sao graváveis + // Se nao forem, apenas logamos aviso - o Raven Service deve lidar com isso + // Nao usamos elevacao para evitar UAC adicional let mut blocked_dirs = Vec::new(); for dir in service_profile_dirs() { if !can_write_dir(&dir) { @@ -1133,53 +1134,46 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> { return Ok(()); } - if has_acl_unlock_flag() { - log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL"); - } else { - log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)"); - } + // Apenas logamos aviso - o serviço RavenService deve lidar com permissões + log_event(format!( + "Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.", + blocked_dirs.iter().map(|d| d.display().to_string()).collect::>() + )); - let mut last_error: Option = None; - for dir in blocked_dirs.iter() { - log_event(&format!( - "Tentando corrigir ACL via UAC (preflight) em {}...", - dir.display() - )); - if let Err(error) = fix_profile_acl(dir) { - last_error = Some(error); - continue; - } - if can_write_dir(dir) { - log_event(&format!( - "ACL ajustada com sucesso em {}", - dir.display() - )); - } else { - last_error = Some(format!( - "continua sem permissão para {} mesmo após preflight", - dir.display() - )); - } - } - - if blocked_dirs.iter().all(|dir| can_write_dir(dir)) { - mark_acl_unlock_flag(); - Ok(()) - } else { - Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into())) - } + // Retornamos Ok para não bloquear o fluxo + // O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios + Ok(()) } fn stop_service_elevated() -> Result<(), String> { - let script = r#" -$ErrorActionPreference='Stop' -$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue -if ($service -and $service.Status -ne 'Stopped') { - Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop - $service.WaitForStatus('Stopped','00:00:10') -} -"#; - run_powershell_elevated(script) + // Tentamos parar o serviço RustDesk sem elevação + // Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso + // Não usamos elevação para evitar UAC adicional + let output = Command::new("sc") + .args(["stop", "RustDesk"]) + .output(); + + match output { + Ok(result) => { + if result.status.success() { + // Aguarda um pouco para o serviço parar + std::thread::sleep(std::time::Duration::from_secs(2)); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + log_event(format!( + "Aviso: não foi possível parar o serviço RustDesk sem elevação: {}", + stderr.trim() + )); + // Retornamos Ok para não bloquear - o serviço pode estar já parado + Ok(()) + } + } + Err(e) => { + log_event(format!("Aviso: falha ao executar sc stop RustDesk: {e}")); + Ok(()) + } + } } fn can_write_dir(dir: &Path) -> bool { @@ -1339,21 +1333,21 @@ fn log_password_replication(secret: &str) { fn log_password_match(path: &Path, secret: &str) { match read_password_from_file(path) { Some(value) if value == secret => { - log_event(&format!( + log_event(format!( "Senha confirmada em {} ({})", path.display(), mask_secret(&value) )); } Some(value) => { - log_event(&format!( + log_event(format!( "Aviso: senha divergente ({}) em {}", mask_secret(&value), path.display() )); } None => { - log_event(&format!( + log_event(format!( "Aviso: chave 'password' não encontrada em {}", path.display() )); @@ -1469,21 +1463,24 @@ fn write_machine_store_object(map: JsonMap) -> Result<(), Str } fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> { - let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new); + let mut map = read_machine_store_object().unwrap_or_default(); map.insert(key.to_string(), value); write_machine_store_object(map) } +#[allow(dead_code)] fn machine_store_key_exists(key: &str) -> bool { read_machine_store_object() .map(|map| map.contains_key(key)) .unwrap_or(false) } +#[allow(dead_code)] fn acl_flag_file_path() -> Option { raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME)) } +#[allow(dead_code)] fn has_acl_unlock_flag() -> bool { if let Some(flag) = acl_flag_file_path() { if flag.exists() { @@ -1493,6 +1490,7 @@ fn has_acl_unlock_flag() -> bool { machine_store_key_exists(RUSTDESK_ACL_STORE_KEY) } +#[allow(dead_code)] fn mark_acl_unlock_flag() { let timestamp = Utc::now().timestamp_millis(); if let Some(flag_path) = acl_flag_file_path() { @@ -1500,7 +1498,7 @@ fn mark_acl_unlock_flag() { let _ = fs::create_dir_all(parent); } if let Err(error) = fs::write(&flag_path, timestamp.to_string()) { - log_event(&format!( + log_event(format!( "Falha ao gravar flag de ACL em {}: {error}", flag_path.display() )); @@ -1508,7 +1506,7 @@ fn mark_acl_unlock_flag() { } if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) { - log_event(&format!( + log_event(format!( "Falha ao registrar flag de ACL no machine-agent: {error}" )); } @@ -1547,7 +1545,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) - .and_then(|v| v.as_str()) .unwrap_or("https://tickets.esdrasrenan.com.br"); - log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id)); + log_event(format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id)); // Monta payload conforme schema esperado pelo backend // Schema: { machineToken, provider, identifier, password?, url?, username?, notes? } @@ -1575,13 +1573,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) - .send()?; if response.status().is_success() { - log_event(&format!("Sync com backend OK: status {}", response.status())); + log_event(format!("Sync com backend OK: status {}", response.status())); Ok(()) } else { let status = response.status(); let body = response.text().unwrap_or_default(); let body_preview = if body.len() > 200 { &body[..200] } else { &body }; - log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview)); + log_event(format!("Sync com backend falhou: {} - {}", status, body_preview)); Err(RustdeskError::CommandFailed { command: "sync_remote_access".to_string(), status: Some(status.as_u16() as i32) diff --git a/apps/desktop/src-tauri/src/service_client.rs b/apps/desktop/src-tauri/src/service_client.rs new file mode 100644 index 0000000..f2af2ed --- /dev/null +++ b/apps/desktop/src-tauri/src/service_client.rs @@ -0,0 +1,244 @@ +//! Cliente IPC para comunicacao com o Raven Service +//! +//! Este modulo permite que o app Tauri se comunique com o Raven Service +//! via Named Pipes para executar operacoes privilegiadas. + +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use std::time::Duration; +use thiserror::Error; + +const PIPE_NAME: &str = r"\\.\pipe\RavenService"; + +#[derive(Debug, Error)] +pub enum ServiceClientError { + #[error("Servico nao disponivel: {0}")] + ServiceUnavailable(String), + + #[error("Erro de comunicacao: {0}")] + CommunicationError(String), + + #[error("Erro de serializacao: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Erro do servico: {message} (code: {code})")] + ServiceError { code: i32, message: String }, + + #[error("Timeout aguardando resposta")] + Timeout, +} + +#[derive(Debug, Serialize)] +struct Request { + id: String, + method: String, + params: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct Response { + id: String, + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ErrorResponse { + code: i32, + message: String, +} + +// ============================================================================= +// Tipos de Resultado +// ============================================================================= + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsbPolicyResult { + pub success: bool, + pub policy: String, + pub error: Option, + pub applied_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskResult { + pub id: String, + pub password: String, + pub installed_version: Option, + pub updated: bool, + pub last_provisioned_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskStatus { + pub installed: bool, + pub running: bool, + pub id: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthCheckResult { + pub status: String, + pub service: String, + pub version: String, + pub timestamp: i64, +} + +// ============================================================================= +// Cliente +// ============================================================================= + +/// Verifica se o servico esta disponivel +pub fn is_service_available() -> bool { + health_check().is_ok() +} + +/// Verifica saude do servico +pub fn health_check() -> Result { + let response = call_service("health_check", serde_json::json!({}))?; + serde_json::from_value(response).map_err(|e| e.into()) +} + +/// Aplica politica de USB +pub fn apply_usb_policy(policy: &str) -> Result { + let response = call_service( + "apply_usb_policy", + serde_json::json!({ "policy": policy }), + )?; + serde_json::from_value(response).map_err(|e| e.into()) +} + +/// Obtem politica de USB atual +pub fn get_usb_policy() -> Result { + let response = call_service("get_usb_policy", serde_json::json!({}))?; + response + .get("policy") + .and_then(|p| p.as_str()) + .map(String::from) + .ok_or_else(|| ServiceClientError::CommunicationError("Resposta invalida".into())) +} + +/// Provisiona RustDesk +pub fn provision_rustdesk( + config: Option<&str>, + password: Option<&str>, + machine_id: Option<&str>, +) -> Result { + let params = serde_json::json!({ + "config": config, + "password": password, + "machineId": machine_id, + }); + let response = call_service("provision_rustdesk", params)?; + serde_json::from_value(response).map_err(|e| e.into()) +} + +/// Obtem status do RustDesk +pub fn get_rustdesk_status() -> Result { + let response = call_service("get_rustdesk_status", serde_json::json!({}))?; + serde_json::from_value(response).map_err(|e| e.into()) +} + +// ============================================================================= +// Comunicacao IPC +// ============================================================================= + +fn call_service( + method: &str, + params: serde_json::Value, +) -> Result { + // Gera ID unico para a requisicao + let id = uuid::Uuid::new_v4().to_string(); + + let request = Request { + id: id.clone(), + method: method.to_string(), + params, + }; + + // Serializa requisicao + let request_json = serde_json::to_string(&request)?; + + // Conecta ao pipe + let mut pipe = connect_to_pipe()?; + + // Envia requisicao + writeln!(pipe, "{}", request_json).map_err(|e| { + ServiceClientError::CommunicationError(format!("Erro ao enviar requisicao: {}", e)) + })?; + pipe.flush().map_err(|e| { + ServiceClientError::CommunicationError(format!("Erro ao flush: {}", e)) + })?; + + // Le resposta + let mut reader = BufReader::new(pipe); + let mut response_line = String::new(); + + reader.read_line(&mut response_line).map_err(|e| { + ServiceClientError::CommunicationError(format!("Erro ao ler resposta: {}", e)) + })?; + + // Parse da resposta + let response: Response = serde_json::from_str(&response_line)?; + + // Verifica se o ID bate + if response.id != id { + return Err(ServiceClientError::CommunicationError( + "ID de resposta nao corresponde".into(), + )); + } + + // Verifica erro + if let Some(error) = response.error { + return Err(ServiceClientError::ServiceError { + code: error.code, + message: error.message, + }); + } + + // Retorna resultado + response + .result + .ok_or_else(|| ServiceClientError::CommunicationError("Resposta sem resultado".into())) +} + +#[cfg(target_os = "windows")] +fn connect_to_pipe() -> Result { + // Tenta conectar ao pipe com retry + let mut attempts = 0; + let max_attempts = 3; + + loop { + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(PIPE_NAME) + { + Ok(file) => return Ok(file), + Err(e) => { + attempts += 1; + if attempts >= max_attempts { + return Err(ServiceClientError::ServiceUnavailable(format!( + "Nao foi possivel conectar ao servico apos {} tentativas: {}", + max_attempts, e + ))); + } + std::thread::sleep(Duration::from_millis(500)); + } + } + } +} + +#[cfg(not(target_os = "windows"))] +fn connect_to_pipe() -> Result { + Err(ServiceClientError::ServiceUnavailable( + "Named Pipes so estao disponiveis no Windows".into(), + )) +} diff --git a/apps/desktop/src-tauri/src/usb_control.rs b/apps/desktop/src-tauri/src/usb_control.rs index 24eebfa..a95e0a5 100644 --- a/apps/desktop/src-tauri/src/usb_control.rs +++ b/apps/desktop/src-tauri/src/usb_control.rs @@ -93,22 +93,10 @@ mod windows_impl { applied_at: Some(now), }), Err(err) => { - // Tenta elevação se faltou permissão + // Se faltou permissão, retorna erro - o serviço deve ser usado + // Não fazemos elevação aqui para evitar UAC adicional if is_permission_error(&err) { - if let Err(elevated_err) = apply_policy_with_elevation(policy) { - return Err(elevated_err); - } - // Revalida a policy após elevação - let current = get_current_policy()?; - if current != policy { - return Err(UsbControlError::PermissionDenied); - } - return Ok(UsbPolicyResult { - success: true, - policy: policy.as_str().to_string(), - error: None, - applied_at: Some(now), - }); + return Err(UsbControlError::PermissionDenied); } Err(err) } @@ -219,10 +207,8 @@ mod windows_impl { key.set_value("WriteProtect", &1u32) .map_err(map_winreg_error)?; - } else { - if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { - let _ = key.set_value("WriteProtect", &0u32); - } + } else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { + let _ = key.set_value("WriteProtect", &0u32); } Ok(()) @@ -269,6 +255,7 @@ mod windows_impl { } } + #[allow(dead_code)] fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> { // Cria script temporário para aplicar as chaves via PowerShell elevado let temp_dir = std::env::temp_dir(); @@ -321,7 +308,7 @@ try {{ policy = policy_str ); - fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?; + fs::write(&script_path, script).map_err(UsbControlError::Io)?; // Start-Process com RunAs para acionar UAC let arg = format!( @@ -333,7 +320,7 @@ try {{ .arg("-Command") .arg(arg) .status() - .map_err(|e| UsbControlError::Io(e))?; + .map_err(UsbControlError::Io)?; if !status.success() { return Err(UsbControlError::PermissionDenied); @@ -362,7 +349,7 @@ try {{ .args(["/target:computer", "/force"]) .creation_flags(CREATE_NO_WINDOW) .output() - .map_err(|e| UsbControlError::Io(e))?; + .map_err(UsbControlError::Io)?; if !output.status.success() { // Nao e critico se falhar, apenas log diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d7672b5..b9a94d1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,6 +50,9 @@ "icons/icon.png", "icons/Raven.png" ], + "resources": { + "../service/target/release/raven-service.exe": "raven-service.exe" + }, "windows": { "webviewInstallMode": { "type": "skip" From 2293a0275ad1525d903758762e0cc4b094be4340 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 09:44:03 -0300 Subject: [PATCH 002/182] fix(chat): melhora confiabilidade da deteccao de novas mensagens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa deteccao dual: timestamp (lastActivityAt) + contador - Adiciona persistencia de estado em ~/.local/share/Raven/chat-state.json - Corrige race condition no servidor com refetch antes do patch - Adiciona campo lastAgentMessageAt no schema do Convex - Adiciona logs de diagnostico detalhados 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/Cargo.lock | 113 +++++++++++++++- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/chat.rs | 210 ++++++++++++++++++++++++++--- convex/schema.ts | 1 + convex/tickets.ts | 15 ++- 5 files changed, 310 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index a3a293a..f5d4b76 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -63,6 +63,7 @@ dependencies = [ "base64 0.22.1", "chrono", "convex", + "dirs 5.0.1", "futures-util", "get_if_addrs", "hostname", @@ -938,13 +939,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[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 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -955,7 +977,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3629,6 +3651,17 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4516,7 +4549,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.3", @@ -4566,7 +4599,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4788,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5324,7 +5357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -6105,6 +6138,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6156,6 +6198,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[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" @@ -6213,6 +6270,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[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" @@ -6231,6 +6294,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[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" @@ -6249,6 +6318,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[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" @@ -6279,6 +6354,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[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" @@ -6297,6 +6378,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[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" @@ -6315,6 +6402,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[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" @@ -6333,6 +6426,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[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" @@ -6395,7 +6494,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 944e0d3..8e26952 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ base64 = "0.22" sha2 = "0.10" convex = "0.10.2" uuid = { version = "1", features = ["v4"] } +dirs = "5" # SSE usa reqwest com stream, nao precisa de websocket [target.'cfg(windows)'.dependencies] diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index d2e52f3..691ac99 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -11,9 +11,11 @@ use parking_lot::Mutex; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; @@ -100,6 +102,77 @@ pub struct SessionStartedEvent { pub session: ChatSession, } +// ============================================================================ +// PERSISTENCIA DE ESTADO +// ============================================================================ + +/// Estado persistido do chat para sobreviver a restarts +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChatPersistedState { + last_unread_count: u32, + sessions: Vec, + saved_at: u64, // Unix timestamp em ms +} + +const STATE_FILE_NAME: &str = "chat-state.json"; +const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos + +fn get_state_file_path() -> Option { + dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME)) +} + +fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) { + let Some(path) = get_state_file_path() else { + return; + }; + + // Criar diretorio se nao existir + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let state = ChatPersistedState { + last_unread_count: last_unread, + sessions: sessions.to_vec(), + saved_at: now, + }; + + if let Ok(json) = serde_json::to_string_pretty(&state) { + let _ = fs::write(&path, json); + crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len()); + } +} + +fn load_chat_state() -> Option { + let path = get_state_file_path()?; + + let json = fs::read_to_string(&path).ok()?; + let state: ChatPersistedState = serde_json::from_str(&json).ok()?; + + // Verificar se estado nao esta muito antigo + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS { + crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)"); + return None; + } + + crate::log_info!( + "[CHAT] Estado restaurado: unread={}, sessions={}", + state.last_unread_count, state.sessions.len() + ); + Some(state) +} + // ============================================================================ // HTTP CLIENT // ============================================================================ @@ -462,10 +535,16 @@ pub struct ChatRuntime { impl ChatRuntime { pub fn new() -> Self { + // Tentar restaurar estado persistido + let (sessions, unread) = match load_chat_state() { + Some(state) => (state.sessions, state.last_unread_count), + None => (Vec::new(), 0), + }; + Self { inner: Arc::new(Mutex::new(None)), - last_sessions: Arc::new(Mutex::new(Vec::new())), - last_unread_count: Arc::new(Mutex::new(0)), + last_sessions: Arc::new(Mutex::new(sessions)), + last_unread_count: Arc::new(Mutex::new(unread)), is_connected: Arc::new(AtomicBool::new(false)), } } @@ -510,7 +589,9 @@ impl ChatRuntime { let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); + crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat"); + crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone); + crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone); let mut backoff_ms: u64 = 1_000; let max_backoff_ms: u64 = 30_000; @@ -522,12 +603,16 @@ impl ChatRuntime { break; } + crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex..."); let client_result = ConvexClient::new(&convex_clone).await; let mut client = match client_result { - Ok(c) => c, + Ok(c) => { + crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso"); + c + } Err(err) => { is_connected.store(false, Ordering::Relaxed); - crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); + crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -550,16 +635,18 @@ impl ChatRuntime { let mut args = BTreeMap::new(); args.insert("machineToken".to_string(), token_clone.clone().into()); + crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates..."); let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await; let mut subscription = match subscribe_result { Ok(sub) => { is_connected.store(true, Ordering::Relaxed); backoff_ms = 1_000; + crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!"); sub } Err(err) => { is_connected.store(false, Ordering::Relaxed); - crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); + crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -579,8 +666,12 @@ impl ChatRuntime { } }; + crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket..."); + let mut update_count: u64 = 0; while let Some(next) = subscription.next().await { + update_count += 1; if stop_clone.load(Ordering::Relaxed) { + crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop"); break; } match next { @@ -601,6 +692,11 @@ impl ChatRuntime { }) .unwrap_or(0); + crate::log_info!( + "[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}", + update_count, has_active, total_unread + ); + process_chat_update( &base_clone, &token_clone, @@ -613,13 +709,13 @@ impl ChatRuntime { .await; } FunctionResult::ConvexError(err) => { - crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); + crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}"); } FunctionResult::ErrorMessage(msg) => { - crate::log_warn!("Erro em checkMachineUpdates: {msg}"); + crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}"); } FunctionResult::Value(other) => { - crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); + crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}"); } } } @@ -627,10 +723,11 @@ impl ChatRuntime { is_connected.store(false, Ordering::Relaxed); if stop_clone.load(Ordering::Relaxed) { + crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop"); break; } - crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar"); + crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar..."); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, @@ -684,8 +781,13 @@ async fn poll_and_process_chat_update( last_sessions: &Arc>>, last_unread_count: &Arc>, ) { + crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling..."); match poll_chat_updates(base_url, token, None).await { Ok(result) => { + crate::log_info!( + "[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}", + result.has_active_sessions, result.total_unread + ); process_chat_update( base_url, token, @@ -698,7 +800,7 @@ async fn poll_and_process_chat_update( .await; } Err(err) => { - crate::log_warn!("Chat fallback poll falhou: {err}"); + crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}"); } } } @@ -712,10 +814,18 @@ async fn process_chat_update( has_active_sessions: bool, total_unread: u32, ) { + crate::log_info!( + "[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}", + has_active_sessions, total_unread + ); + // Buscar sessoes completas para ter dados corretos let mut current_sessions = if has_active_sessions { - fetch_sessions(base_url, token).await.unwrap_or_default() + let sessions = fetch_sessions(base_url, token).await.unwrap_or_default(); + crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len()); + sessions } else { + crate::log_info!("[CHAT DEBUG] Sem sessoes ativas"); Vec::new() }; @@ -776,14 +886,58 @@ async fn process_chat_update( } } - // Atualizar cache de sessoes - *last_sessions.lock() = current_sessions.clone(); + // ========================================================================= + // DETECCAO ROBUSTA DE NOVAS MENSAGENS + // Usa DUAS estrategias: timestamp E contador (belt and suspenders) + // ========================================================================= - // Verificar mensagens nao lidas let prev_unread = *last_unread_count.lock(); - let new_messages = total_unread > prev_unread; + + // Estrategia 1: Detectar por lastActivityAt de cada sessao + // Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem + let mut detected_by_activity = false; + let mut activity_details = String::new(); + + for session in ¤t_sessions { + let prev_activity = prev_sessions + .iter() + .find(|s| s.session_id == session.session_id) + .map(|s| s.last_activity_at) + .unwrap_or(0); + + // Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente + if session.last_activity_at > prev_activity && session.unread_count > 0 { + detected_by_activity = true; + activity_details = format!( + "sessao={} activity: {} -> {} unread={}", + session.ticket_id, prev_activity, session.last_activity_at, session.unread_count + ); + break; + } + } + + // Estrategia 2: Fallback por contador total (metodo original) + let detected_by_count = total_unread > prev_unread; + + // Nova mensagem se QUALQUER estrategia detectar + let new_messages = detected_by_activity || detected_by_count; + + // Log detalhado para diagnostico + crate::log_info!( + "[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}", + detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages + ); + if detected_by_activity { + crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details); + } + + // Atualizar caches APOS deteccao (importante: manter ordem) + *last_sessions.lock() = current_sessions.clone(); *last_unread_count.lock() = total_unread; + // Persistir estado para sobreviver a restarts + save_chat_state(total_unread, ¤t_sessions); + // Sempre emitir unread-update let _ = app.emit( "raven://chat/unread-update", @@ -795,9 +949,17 @@ async fn process_chat_update( // Notificar novas mensagens - mostrar chat minimizado com badge if new_messages && total_unread > 0 { - let new_count = total_unread - prev_unread; + let new_count = if total_unread > prev_unread { + total_unread - prev_unread + } else { + 1 // Se detectou por activity mas contador nao mudou, assumir 1 nova + }; - crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread); + crate::log_info!( + "[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}", + new_count, total_unread, + if detected_by_activity { "activity" } else { "count" } + ); let _ = app.emit( "raven://chat/new-message", @@ -885,6 +1047,16 @@ async fn process_chat_update( .title(notification_title) .body(¬ification_body) .show(); + } else { + // Log para debug quando NAO ha novas mensagens + if total_unread == 0 { + crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)"); + } else if !new_messages { + crate::log_info!( + "[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})", + prev_unread, total_unread + ); + } } } diff --git a/convex/schema.ts b/convex/schema.ts index 3402484..a327224 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -478,6 +478,7 @@ export default defineSchema({ startedAt: v.number(), endedAt: v.optional(v.number()), lastActivityAt: v.number(), + lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel) unreadByMachine: v.optional(v.number()), unreadByAgent: v.optional(v.number()), }) diff --git a/convex/tickets.ts b/convex/tickets.ts index e2c5602..2a7af9f 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3734,6 +3734,8 @@ export const postChatMessage = mutation({ await ctx.db.patch(ticketId, { updatedAt: now }) // Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa + // IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions + // O Convex faz retry automatico em caso de OCC conflict const actorRole = participant.role?.toUpperCase() ?? "" if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) { const activeSession = await ctx.db @@ -3743,10 +3745,15 @@ export const postChatMessage = mutation({ .first() if (activeSession) { - await ctx.db.patch(activeSession._id, { - unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1, - lastActivityAt: now, - }) + // Refetch para garantir valor mais recente (OCC protection) + const freshSession = await ctx.db.get(activeSession._id) + if (freshSession) { + await ctx.db.patch(activeSession._id, { + unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1, + lastActivityAt: now, + lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente + }) + } } } From 300179279a72d0c2b8f17dbdc6edfc39a93326e3 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 10:10:04 -0300 Subject: [PATCH 003/182] feat(chat): adiciona separador de dias e melhora layout do header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona separador de data entre mensagens de dias diferentes (estilo WhatsApp) - Mostra "Hoje", "Ontem" ou data completa (ex: "segunda-feira, 15 de dezembro") - Separa hostname da maquina em linha propria no header - Hostname com truncate e tooltip para nomes longos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/chat/chat-widget.tsx | 75 +++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index ace0ddd..5083ab5 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -53,6 +53,40 @@ function formatTime(timestamp: number) { }) } +function formatDateSeparator(timestamp: number) { + const date = new Date(timestamp) + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + const isToday = date.toDateString() === today.toDateString() + const isYesterday = date.toDateString() === yesterday.toDateString() + + if (isToday) return "Hoje" + if (isYesterday) return "Ontem" + + return date.toLocaleDateString("pt-BR", { + weekday: "long", + day: "2-digit", + month: "long", + }) +} + +function getDateKey(timestamp: number) { + return new Date(timestamp).toDateString() +} + +// Componente separador de data (estilo WhatsApp) +function DateSeparator({ timestamp }: { timestamp: number }) { + return ( +
+
+ {formatDateSeparator(timestamp)} +
+
+ ) +} + type ChatSession = { ticketId: string ticketRef: number @@ -661,16 +695,22 @@ export function ChatWidget() { )} {activeSession && ( - - #{activeSession.ticketRef} - {machineHostname && - {machineHostname}} - - +
+ + #{activeSession.ticketRef} + + + {machineHostname && ( + + {machineHostname} + + )} +
)} @@ -757,16 +797,22 @@ export function ChatWidget() { ) : (
- {messages.map((msg) => { + {messages.map((msg, index) => { const isOwn = String(msg.authorId) === String(viewerId) const bodyText = msg.body?.trim() ?? "" const shouldShowBody = bodyText.length > 0 && !(bodyText === "[Anexo]" && (msg.attachments?.length ?? 0) > 0) + + // Verificar se precisa mostrar separador de data + const prevMsg = index > 0 ? messages[index - 1] : null + const showDateSeparator = !prevMsg || getDateKey(msg.createdAt) !== getDateKey(prevMsg.createdAt) + return ( -
+ {showDateSeparator && } +
+
) })}
From 1bc08d3a5f055056cb5a6789714e3ddf959bdd35 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 10:42:08 -0300 Subject: [PATCH 004/182] =?UTF-8?q?feat:=20adiciona=20fluxo=20de=20redefin?= =?UTF-8?q?i=C3=A7=C3=A3o=20de=20senha=20e=20melhora=20p=C3=A1gina=20de=20?= =?UTF-8?q?configura=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona página /recuperar para solicitar redefinição de senha - Adiciona página /redefinir-senha para definir nova senha com token - Cria APIs /api/auth/forgot-password e /api/auth/reset-password - Adiciona notificação por e-mail quando ticket é criado - Repagina página de configurações removendo informações técnicas - Adiciona script de teste para todos os tipos de e-mail - Corrige acentuações em templates de e-mail 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- convex/ticketNotifications.ts | 37 ++ convex/tickets.ts | 19 + scripts/test-all-emails.tsx | 188 +++++++ src/app/api/auth/forgot-password/route.ts | 101 ++++ src/app/api/auth/reset-password/route.ts | 97 ++++ .../recuperar/forgot-password-page-client.tsx | 188 +++++++ src/app/recuperar/page.tsx | 11 + src/app/redefinir-senha/page.tsx | 11 + .../reset-password-page-client.tsx | 278 ++++++++++ src/components/settings/settings-content.tsx | 494 ++++++++++++------ 10 files changed, 1258 insertions(+), 166 deletions(-) create mode 100644 scripts/test-all-emails.tsx create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/recuperar/forgot-password-page-client.tsx create mode 100644 src/app/recuperar/page.tsx create mode 100644 src/app/redefinir-senha/page.tsx create mode 100644 src/app/redefinir-senha/reset-password-page-client.tsx diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 9d1d405..4e3b540 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -281,6 +281,43 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: } } +export const sendTicketCreatedEmail = action({ + args: { + to: v.string(), + ticketId: v.string(), + reference: v.number(), + subject: v.string(), + priority: v.string(), + }, + handler: async (_ctx, { to, ticketId, reference, subject, priority }) => { + const smtp = buildSmtpConfig() + if (!smtp) { + console.warn("SMTP not configured; skipping ticket created email") + return { skipped: true } + } + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + + const priorityLabels: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Urgente", + } + const priorityLabel = priorityLabels[priority] ?? priority + + const mailSubject = `Novo chamado #${reference} aberto` + const html = await renderSimpleNotificationEmailHtml({ + title: `Novo chamado #${reference} aberto`, + message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`, + ctaLabel: "Ver chamado", + ctaUrl: url, + }) + await sendSmtpMail(smtp, to, mailSubject, html) + return { ok: true } + }, +}) + export const sendPublicCommentEmail = action({ args: { to: v.string(), diff --git a/convex/tickets.ts b/convex/tickets.ts index 2a7af9f..428fd74 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -2456,6 +2456,25 @@ export const create = mutation({ createdAt: now, }); + // Notificação por e-mail: ticket criado para o solicitante + try { + const requesterEmail = requester?.email + if (requesterEmail) { + const schedulerRunAfter = ctx.scheduler?.runAfter + if (typeof schedulerRunAfter === "function") { + await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, { + to: requesterEmail, + ticketId: String(id), + reference: nextRef, + subject, + priority: args.priority, + }) + } + } + } catch (e) { + console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e) + } + if (initialAssigneeId && initialAssignee) { await ctx.db.insert("ticketEvents", { ticketId: id, diff --git a/scripts/test-all-emails.tsx b/scripts/test-all-emails.tsx new file mode 100644 index 0000000..ef9929a --- /dev/null +++ b/scripts/test-all-emails.tsx @@ -0,0 +1,188 @@ +import * as React from "react" +import dotenv from "dotenv" +import { render } from "@react-email/render" + +import { sendSmtpMail } from "@/server/email-smtp" +import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email" +import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email" + +dotenv.config({ path: ".env.local" }) +dotenv.config({ path: ".env" }) + +function getSmtpConfig() { + const host = process.env.SMTP_HOST + const port = process.env.SMTP_PORT + const username = process.env.SMTP_USER + const password = process.env.SMTP_PASS + const fromEmail = process.env.SMTP_FROM_EMAIL + const fromName = process.env.SMTP_FROM_NAME ?? "Raven" + + if (!host || !port || !username || !password || !fromEmail) return null + + return { + host, + port: Number(port), + username, + password, + from: `"${fromName}" <${fromEmail}>`, + tls: process.env.SMTP_SECURE === "true", + rejectUnauthorized: false, + timeoutMs: 15000, + } +} + +type EmailScenario = { + name: string + subject: string + render: () => Promise +} + +const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tickets.esdrasrenan.com.br" + +const scenarios: EmailScenario[] = [ + { + name: "Ticket Criado", + subject: "[TESTE] Novo chamado #41025 aberto", + render: async () => { + const props: SimpleNotificationEmailProps = { + title: "Novo chamado #41025 aberto", + message: "Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: Computador reiniciando sozinho\nPrioridade: Alta\nStatus: Pendente", + ctaLabel: "Ver chamado", + ctaUrl: `${baseUrl}/portal/tickets/test123`, + } + return render(, { pretty: true }) + }, + }, + { + name: "Ticket Resolvido", + subject: "[TESTE] Chamado #41025 foi encerrado", + render: async () => { + const props: SimpleNotificationEmailProps = { + title: "Chamado #41025 encerrado", + message: "O chamado 'Computador reiniciando sozinho' foi marcado como concluído.\n\nCaso necessário, você pode responder pelo portal para reabrir dentro do prazo.", + ctaLabel: "Ver detalhes", + ctaUrl: `${baseUrl}/portal/tickets/test123`, + } + return render(, { pretty: true }) + }, + }, + { + name: "Novo Comentário", + subject: "[TESTE] Atualização no chamado #41025", + render: async () => { + const props: SimpleNotificationEmailProps = { + title: "Nova atualização no seu chamado #41025", + message: "Um novo comentário foi adicionado ao chamado 'Computador reiniciando sozinho'.\n\nClique abaixo para visualizar e responder pelo portal.", + ctaLabel: "Abrir e responder", + ctaUrl: `${baseUrl}/portal/tickets/test123`, + } + return render(, { pretty: true }) + }, + }, + { + name: "Automação - Mudança de Prioridade", + subject: "[TESTE] Prioridade alterada no chamado #41025", + render: async () => { + const props: AutomationEmailProps = { + title: "Prioridade alterada para Urgente", + message: "A prioridade do seu chamado foi alterada automaticamente pelo sistema.\n\nIsso pode ter ocorrido devido a regras de SLA ou categorização automática.", + ticket: { + reference: 41025, + subject: "Computador reiniciando sozinho", + companyName: "Paulicon Contabil", + status: "AWAITING_ATTENDANCE", + priority: "URGENT", + requesterName: "Renan", + assigneeName: "Administrador", + }, + ctaLabel: "Ver chamado", + ctaUrl: `${baseUrl}/portal/tickets/test123`, + } + return render(, { pretty: true }) + }, + }, + { + name: "Automação - Atribuição de Agente", + subject: "[TESTE] Agente atribuído ao chamado #41025", + render: async () => { + const props: AutomationEmailProps = { + title: "Agente atribuído ao seu chamado", + message: "O agente Administrador foi automaticamente atribuído ao seu chamado e entrará em contato em breve.", + ticket: { + reference: 41025, + subject: "Computador reiniciando sozinho", + companyName: "Paulicon Contabil", + status: "AWAITING_ATTENDANCE", + priority: "HIGH", + requesterName: "Renan", + assigneeName: "Administrador", + }, + ctaLabel: "Ver chamado", + ctaUrl: `${baseUrl}/portal/tickets/test123`, + } + return render(, { pretty: true }) + }, + }, + { + name: "Redefinição de Senha", + subject: "[TESTE] Redefinição de senha - Raven", + render: async () => { + const props: SimpleNotificationEmailProps = { + title: "Redefinição de Senha", + message: "Recebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail.\n\nEste link expira em 1 hora.", + ctaLabel: "Redefinir Senha", + ctaUrl: `${baseUrl}/redefinir-senha?token=abc123def456`, + } + return render(, { pretty: true }) + }, + }, +] + +async function main() { + const targetEmail = process.argv[2] ?? "renan.pac@paulicon.com.br" + + const smtp = getSmtpConfig() + if (!smtp) { + console.error("SMTP não configurado. Defina as variáveis SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL") + process.exit(1) + } + + console.log("=".repeat(60)) + console.log("Teste de E-mails - Sistema de Chamados Raven") + console.log("=".repeat(60)) + console.log(`\nDestinatario: ${targetEmail}`) + console.log(`SMTP: ${smtp.host}:${smtp.port}`) + console.log(`De: ${smtp.from}`) + console.log(`\nEnviando ${scenarios.length} e-mails de teste...\n`) + + let success = 0 + let failed = 0 + + for (const scenario of scenarios) { + try { + process.stdout.write(` ${scenario.name}... `) + const html = await scenario.render() + await sendSmtpMail(smtp, targetEmail, scenario.subject, html) + console.log("OK") + success++ + // Pequeno delay entre envios para evitar rate limit + await new Promise((resolve) => setTimeout(resolve, 500)) + } catch (error) { + console.log(`ERRO: ${error instanceof Error ? error.message : error}`) + failed++ + } + } + + console.log("\n" + "=".repeat(60)) + console.log(`Resultado: ${success} enviados, ${failed} falharam`) + console.log("=".repeat(60)) + + if (failed > 0) { + process.exit(1) + } +} + +main().catch((error) => { + console.error("Erro fatal:", error) + process.exit(1) +}) diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..dd2ed23 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,101 @@ +import crypto from "crypto" + +import { render } from "@react-email/render" +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" +import { sendSmtpMail } from "@/server/email-smtp" +import SimpleNotificationEmail from "../../../../../emails/simple-notification-email" + +function getSmtpConfig() { + const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS + const port = process.env.SMTP_PORT + const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME + const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD + const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL + const fromName = process.env.SMTP_FROM_NAME ?? "Raven" + + if (!host || !port || !username || !password || !fromEmail) return null + + return { + host, + port: Number(port), + username, + password, + from: `"${fromName}" <${fromEmail}>`, + tls: process.env.SMTP_SECURE === "true", + starttls: process.env.SMTP_SECURE !== "true", + rejectUnauthorized: false, + timeoutMs: 15000, + } +} + +export async function POST(request: Request) { + try { + const body = await request.json() + const { email } = body + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 }) + } + + const normalizedEmail = email.toLowerCase().trim() + + // Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança) + const user = await prisma.authUser.findFirst({ + where: { email: normalizedEmail }, + }) + + // Sempre retorna sucesso para não revelar se o e-mail existe + if (!user) { + return NextResponse.json({ success: true }) + } + + // Gera um token seguro + const token = crypto.randomBytes(32).toString("hex") + const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora + + // Remove tokens anteriores do mesmo usuário + await prisma.authVerification.deleteMany({ + where: { + identifier: `password-reset:${user.id}`, + }, + }) + + // Salva o novo token + await prisma.authVerification.create({ + data: { + identifier: `password-reset:${user.id}`, + value: token, + expiresAt, + }, + }) + + // Envia o e-mail + const smtp = getSmtpConfig() + if (!smtp) { + console.error("[FORGOT_PASSWORD] SMTP não configurado") + return NextResponse.json({ success: true }) // Não revela erro de configuração + } + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br" + const resetUrl = `${baseUrl}/redefinir-senha?token=${token}` + + const html = await render( + SimpleNotificationEmail({ + title: "Redefinição de Senha", + message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`, + ctaLabel: "Redefinir Senha", + ctaUrl: resetUrl, + }), + { pretty: true } + ) + + await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[FORGOT_PASSWORD] Erro:", error) + return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 }) + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..2c7fe6e --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server" +import { hashPassword } from "better-auth/crypto" + +import { prisma } from "@/lib/prisma" + +export async function POST(request: Request) { + try { + const body = await request.json() + const { token, password } = body + + if (!token || typeof token !== "string") { + return NextResponse.json({ error: "Token inválido" }, { status: 400 }) + } + + if (!password || typeof password !== "string" || password.length < 6) { + return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 }) + } + + // Busca o token de verificação + const verification = await prisma.authVerification.findFirst({ + where: { + value: token, + identifier: { startsWith: "password-reset:" }, + expiresAt: { gt: new Date() }, + }, + }) + + if (!verification) { + return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 }) + } + + // Extrai o userId do identifier + const userId = verification.identifier.replace("password-reset:", "") + + // Busca o usuário + const user = await prisma.authUser.findUnique({ + where: { id: userId }, + }) + + if (!user) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 }) + } + + // Hash da nova senha + const hashedPassword = await hashPassword(password) + + // Atualiza a conta do usuário com a nova senha + await prisma.authAccount.updateMany({ + where: { + userId: user.id, + providerId: "credential", + }, + data: { + password: hashedPassword, + }, + }) + + // Remove o token usado + await prisma.authVerification.delete({ + where: { id: verification.id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[RESET_PASSWORD] Erro:", error) + return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 }) + } +} + +// GET para validar se o token é válido (usado pela página) +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const token = searchParams.get("token") + + if (!token) { + return NextResponse.json({ valid: false, error: "Token não fornecido" }) + } + + const verification = await prisma.authVerification.findFirst({ + where: { + value: token, + identifier: { startsWith: "password-reset:" }, + expiresAt: { gt: new Date() }, + }, + }) + + if (!verification) { + return NextResponse.json({ valid: false, error: "Token inválido ou expirado" }) + } + + return NextResponse.json({ valid: true }) + } catch (error) { + console.error("[RESET_PASSWORD] Erro ao validar token:", error) + return NextResponse.json({ valid: false, error: "Erro ao validar token" }) + } +} diff --git a/src/app/recuperar/forgot-password-page-client.tsx b/src/app/recuperar/forgot-password-page-client.tsx new file mode 100644 index 0000000..46562aa --- /dev/null +++ b/src/app/recuperar/forgot-password-page-client.tsx @@ -0,0 +1,188 @@ +"use client" + +import { useState } from "react" +import Image from "next/image" +import Link from "next/link" +import dynamic from "next/dynamic" +import { ArrowLeft, Loader2, Mail } from "lucide-react" +import { toast } from "sonner" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" + +const ShaderBackground = dynamic( + () => import("@/components/background-paper-shaders-wrapper"), + { ssr: false } +) + +export function ForgotPasswordPageClient() { + const [email, setEmail] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (isSubmitting) return + if (!email) { + toast.error("Informe seu e-mail") + return + } + + setIsSubmitting(true) + try { + const response = await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + + const data = await response.json() + + if (!response.ok) { + toast.error(data.error ?? "Erro ao processar solicitação") + setIsSubmitting(false) + return + } + + setIsSuccess(true) + toast.success("E-mail de recuperação enviado!") + } catch (error) { + console.error("Erro ao solicitar recuperação", error) + toast.error("Não foi possível processar. Tente novamente") + setIsSubmitting(false) + } + } + + return ( +
+
+
+ +
+ Sistema de chamados + Por Rever Tecnologia +
+ +
+
+
+ {isSuccess ? ( + + ) : ( + + )} +
+
+
+ Logotipo Raven +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} + +function ForgotPasswordForm({ + email, + setEmail, + isSubmitting, + onSubmit, +}: { + email: string + setEmail: (value: string) => void + isSubmitting: boolean + onSubmit: (event: React.FormEvent) => void +}) { + return ( +
+ +
+

Recuperar senha

+

+ Informe seu e-mail para receber as instruções de redefinição de senha. +

+
+ + E-mail + setEmail(event.target.value)} + disabled={isSubmitting} + required + /> + + + + + + + + Voltar para o login + + +
+
+ ) +} + +function SuccessMessage({ email }: { email: string }) { + return ( + +
+
+ +
+

Verifique seu e-mail

+

+ Se existir uma conta com o e-mail {email}, você receberá um link para redefinir sua senha. +

+ + O link expira em 1 hora. Verifique também sua caixa de spam. + +
+
+ + + Voltar para o login + +
+
+ ) +} diff --git a/src/app/recuperar/page.tsx b/src/app/recuperar/page.tsx new file mode 100644 index 0000000..7b510a1 --- /dev/null +++ b/src/app/recuperar/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react" + +import { ForgotPasswordPageClient } from "./forgot-password-page-client" + +export default function ForgotPasswordPage() { + return ( + Carregando...
}> + + + ) +} diff --git a/src/app/redefinir-senha/page.tsx b/src/app/redefinir-senha/page.tsx new file mode 100644 index 0000000..8fe78ac --- /dev/null +++ b/src/app/redefinir-senha/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react" + +import { ResetPasswordPageClient } from "./reset-password-page-client" + +export default function ResetPasswordPage() { + return ( + Carregando...
}> + + + ) +} diff --git a/src/app/redefinir-senha/reset-password-page-client.tsx b/src/app/redefinir-senha/reset-password-page-client.tsx new file mode 100644 index 0000000..96122d0 --- /dev/null +++ b/src/app/redefinir-senha/reset-password-page-client.tsx @@ -0,0 +1,278 @@ +"use client" + +import { useEffect, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import dynamic from "next/dynamic" +import { ArrowLeft, CheckCircle, Loader2, XCircle } from "lucide-react" +import { toast } from "sonner" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" + +const ShaderBackground = dynamic( + () => import("@/components/background-paper-shaders-wrapper"), + { ssr: false } +) + +type PageState = "loading" | "invalid" | "form" | "success" + +export function ResetPasswordPageClient() { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams?.get("token") ?? "" + + const [pageState, setPageState] = useState("loading") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + if (!token) { + setPageState("invalid") + return + } + + async function validateToken() { + try { + const response = await fetch(`/api/auth/reset-password?token=${token}`) + const data = await response.json() + setPageState(data.valid ? "form" : "invalid") + } catch { + setPageState("invalid") + } + } + + validateToken() + }, [token]) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (isSubmitting) return + + if (password.length < 6) { + toast.error("A senha deve ter pelo menos 6 caracteres") + return + } + + if (password !== confirmPassword) { + toast.error("As senhas não conferem") + return + } + + setIsSubmitting(true) + try { + const response = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }) + + const data = await response.json() + + if (!response.ok) { + toast.error(data.error ?? "Erro ao redefinir senha") + setIsSubmitting(false) + return + } + + setPageState("success") + toast.success("Senha redefinida com sucesso!") + } catch (error) { + console.error("Erro ao redefinir senha", error) + toast.error("Não foi possível redefinir a senha. Tente novamente") + setIsSubmitting(false) + } + } + + return ( +
+
+
+ +
+ Sistema de chamados + Por Rever Tecnologia +
+ +
+
+
+ {pageState === "loading" && } + {pageState === "invalid" && } + {pageState === "form" && ( + + )} + {pageState === "success" && } +
+
+
+ Logotipo Raven +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} + +function LoadingState() { + return ( + +
+ +

Validando seu link...

+
+
+ ) +} + +function InvalidTokenState() { + return ( + +
+
+ +
+

Link inválido

+

+ Este link de redefinição de senha é inválido ou já expirou. +

+ + Solicite um novo link na página de recuperação de senha. + +
+
+ + + + Voltar para o login + +
+
+ ) +} + +function ResetPasswordForm({ + password, + setPassword, + confirmPassword, + setConfirmPassword, + isSubmitting, + onSubmit, +}: { + password: string + setPassword: (value: string) => void + confirmPassword: string + setConfirmPassword: (value: string) => void + isSubmitting: boolean + onSubmit: (event: React.FormEvent) => void +}) { + return ( +
+ +
+

Nova senha

+

+ Digite sua nova senha abaixo. Escolha uma senha segura com pelo menos 6 caracteres. +

+
+ + Nova senha + setPassword(event.target.value)} + disabled={isSubmitting} + required + minLength={6} + /> + + + Confirmar senha + setConfirmPassword(event.target.value)} + disabled={isSubmitting} + required + minLength={6} + /> + + + + + + + + Voltar para o login + + +
+
+ ) +} + +function SuccessState() { + return ( + +
+
+ +
+

Senha redefinida!

+

+ Sua senha foi alterada com sucesso. Você já pode fazer login com sua nova senha. +

+
+
+ +
+
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index c8c65f5..7f8edd2 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -1,17 +1,37 @@ "use client" -import { useMemo, useState } from "react" +import { FormEvent, useMemo, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" -import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText, BellRing, ClipboardList } from "lucide-react" +import { + Settings2, + Share2, + ShieldCheck, + UserPlus, + Users2, + Layers3, + MessageSquareText, + BellRing, + ClipboardList, + LogOut, + Mail, + Key, + User, + Building2, + Shield, + Clock, + Camera, +} from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { useAuth, signOut } from "@/lib/auth-client" -import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { LucideIcon } from "lucide-react" @@ -34,87 +54,79 @@ const ROLE_LABELS: Record = { customer: "Cliente", } +const ROLE_COLORS: Record = { + admin: "bg-violet-100 text-violet-700 border-violet-200", + manager: "bg-blue-100 text-blue-700 border-blue-200", + agent: "bg-cyan-100 text-cyan-700 border-cyan-200", + collaborator: "bg-slate-100 text-slate-700 border-slate-200", + customer: "bg-slate-100 text-slate-700 border-slate-200", +} + const SETTINGS_ACTIONS: SettingsAction[] = [ { title: "Campos personalizados", - description: "Configure campos extras por formulário e empresa para enriquecer os tickets.", + description: "Configure campos extras para enriquecer os tickets.", href: "/admin/custom-fields", - cta: "Abrir campos", + cta: "Configurar", requiredRole: "admin", icon: ClipboardList, }, { - title: "Times & papéis", - description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.", + title: "Times e equipes", + description: "Gerencie times e atribua permissões por equipe.", href: "/admin/teams", - cta: "Gerenciar times", + cta: "Gerenciar", requiredRole: "admin", icon: Users2, }, { - title: "Filas", - description: "Configure filas, horários de atendimento e regras automáticas de distribuição.", + title: "Filas de atendimento", + description: "Configure filas e regras de distribuição.", href: "/admin/channels", - cta: "Abrir filas", + cta: "Configurar", requiredRole: "admin", icon: Share2, }, { - title: "Categorias e formulários", - description: "Mantenha categorias padronizadas e templates de formulário alinhados à operação.", + title: "Categorias", + description: "Gerencie categorias e formulários de tickets.", href: "/admin/fields", - cta: "Gerenciar categorias", + cta: "Gerenciar", requiredRole: "admin", icon: Layers3, }, { - title: "Equipe e convites", - description: "Convide novos usuários, gerencie papéis e acompanhe quem tem acesso ao workspace.", - href: "/admin", - cta: "Abrir administração", + title: "Usuários e convites", + description: "Convide novos usuários e gerencie acessos.", + href: "/admin/users", + cta: "Gerenciar", requiredRole: "admin", icon: UserPlus, }, { title: "Templates de comentários", - description: "Gerencie mensagens rápidas utilizadas nos atendimentos.", + description: "Mensagens rápidas para os atendimentos.", href: "/settings/templates", - cta: "Abrir templates", + cta: "Gerenciar", requiredRole: "staff", icon: MessageSquareText, }, { - title: "Notificacoes por e-mail", - description: "Configure quais notificacoes por e-mail deseja receber e como recebe-las.", + title: "Notificações", + description: "Configure quais e-mails deseja receber.", href: "/settings/notifications", - cta: "Configurar notificacoes", + cta: "Configurar", requiredRole: "staff", icon: BellRing, }, { - title: "Preferencias da equipe", - description: "Defina padroes de notificacao e comportamento do modo play para toda a equipe.", - href: "#preferencias", - cta: "Ajustar preferencias", - requiredRole: "staff", - icon: Settings2, - }, - { - title: "Políticas e segurança", - description: "Acompanhe SLAs críticos, rastreie integrações e revise auditorias de acesso.", + title: "Políticas de SLA", + description: "Acompanhe e configure níveis de serviço.", href: "/admin/slas", - cta: "Revisar SLAs", + cta: "Gerenciar", requiredRole: "admin", icon: ShieldCheck, }, - { - title: "Alertas enviados", - description: "Histórico de alertas e notificações emitidos pela plataforma.", - href: "/admin/alerts", - cta: "Ver alertas", - requiredRole: "admin", - icon: BellRing, - }, ] export function SettingsContent() { @@ -124,7 +136,7 @@ export function SettingsContent() { const normalizedRole = session?.user.role?.toLowerCase() ?? "agent" const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente" - const tenant = session?.user.tenantId ?? DEFAULT_TENANT_ID + const roleColorClass = ROLE_COLORS[normalizedRole] ?? ROLE_COLORS.agent const sessionExpiry = useMemo(() => { const expiresAt = session?.session?.expiresAt @@ -157,126 +169,134 @@ export function SettingsContent() { return false } + const initials = useMemo(() => { + const name = session?.user.name ?? "" + const parts = name.split(" ").filter(Boolean) + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase() + } + return name.slice(0, 2).toUpperCase() || "U" + }, [session?.user.name]) + return ( -
-
- - - Perfil - - Dados sincronizados via Better Auth e utilizados para provisionamento no Convex. - - - -
-
-
Nome
-
{session?.user.name ?? "—"}
-
-
-
E-mail
-
{session?.user.email ?? "—"}
-
-
-
Tenant
-
- - {tenant} - -
-
-
-
Papel
-
- - {roleLabel} - -
-
-
- -
-
- Sessão ativa - {session?.session?.id ? ( - - {session.session.id.slice(0, 8)}… - - ) : null} -
-

{sessionExpiry ? `Expira em ${sessionExpiry}` : "Sessão em background com renovação automática."}

-

- Alterações no perfil refletem instantaneamente no painel administrativo e nos relatórios. -

-
-
- - - - - -
- - - - Preferências rápidas - - - Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho. - - - - - - - - -
+
+ {/* Perfil do usuario */}
-

Administração do workspace

-

- Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador. +

Meu perfil

+

+ Suas informações pessoais e configurações de conta.

-
+ +
+ + +
+ + + + + Acesso + + + +
+ Papel + + {roleLabel} + +
+ +
+ Sessão +
+
+ Ativa +
+
+ {sessionExpiry && ( +

+ Expira em {sessionExpiry} +

+ )} + + + + + + + + + + + Segurança + + + + + + +
+
+
+ + {/* Configuracoes do workspace */} +
+
+

Configurações

+

+ Gerencie times, filas, categorias e outras configurações do sistema. +

+
+
{SETTINGS_ACTIONS.map((action) => { const allowed = canAccess(action.requiredRole) const Icon = action.icon return ( - - -
+ + +
-
+
{action.title} - {action.description} + + {action.description} +
- {!allowed ? Restrito : null} - + {allowed ? ( - ) : ( - )} @@ -286,33 +306,175 @@ export function SettingsContent() { })}
-
) } -type PreferenceItemProps = { - title: string - description: string -} +function ProfileEditCard({ + name, + email, + avatarUrl, + initials, +}: { + name: string + email: string + avatarUrl: string | null + initials: string +}) { + const [editName, setEditName] = useState(name) + const [editEmail, setEditEmail] = useState(email) + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) -function PreferenceItem({ title, description }: PreferenceItemProps) { - const [enabled, setEnabled] = useState(false) + const hasChanges = useMemo(() => { + const nameChanged = editName.trim() !== name + const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase() + const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0 + return nameChanged || emailChanged || passwordChanged + }, [editName, name, editEmail, email, newPassword, confirmPassword]) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (!hasChanges) { + toast.info("Nenhuma alteração a salvar.") + return + } + + const payload: Record = {} + const trimmedEmail = editEmail.trim() + if (trimmedEmail && trimmedEmail.toLowerCase() !== email.toLowerCase()) { + payload.email = trimmedEmail + } + if (newPassword || confirmPassword) { + if (newPassword !== confirmPassword) { + toast.error("As senhas não conferem") + return + } + if (newPassword.length < 8) { + toast.error("A senha deve ter pelo menos 8 caracteres") + return + } + payload.password = { newPassword, confirmPassword } + } + + setIsSubmitting(true) + try { + const res = await fetch("/api/portal/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" })) + const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil" + toast.error(message) + return + } + const data = (await res.json().catch(() => null)) as { email?: string } | null + if (data?.email) { + setEditEmail(data.email) + } + setNewPassword("") + setConfirmPassword("") + toast.success("Dados atualizados com sucesso!") + } catch (error) { + console.error("Falha ao atualizar perfil", error) + toast.error("Não foi possível atualizar o perfil.") + } finally { + setIsSubmitting(false) + } + } return ( -
-
-

{title}

-

{description}

-
- -
+ + +
+
+ + + + {initials} + + + +
+
+ {name || "Usuário"} + {email} +
+
+
+ +
+
+
+ + setEditName(e.target.value)} + placeholder="Seu nome" + disabled + className="bg-neutral-50" + /> +

Editável apenas por administradores

+
+
+ + setEditEmail(e.target.value)} + placeholder="seu@email.com" + /> +
+
+ +
+ +
+ setNewPassword(e.target.value)} + placeholder="Nova senha" + /> + setConfirmPassword(e.target.value)} + placeholder="Confirmar senha" + /> +
+

+ Mínimo de 8 caracteres. Deixe em branco se não quiser alterar. +

+
+
+ +
+ +
+
) } From ab7dfa81ca3a5e1392124a59551e427d1ec94b2d Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 11:00:02 -0300 Subject: [PATCH 005/182] =?UTF-8?q?feat:=20melhora=20p=C3=A1gina=20de=20pe?= =?UTF-8?q?rfil=20e=20integra=20prefer=C3=AAncias=20de=20notifica=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Atualiza cores das badges para padrão cyan do projeto - Adiciona degradê no header do card de perfil - Implementa upload de foto de perfil via API Convex - Integra notificações do Convex com preferências do usuário - Cria API /api/notifications/send para verificar preferências - Melhora layout das páginas de login/recuperação com degradê - Adiciona badge "Helpdesk" e título "Raven" consistente 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- convex/ticketNotifications.ts | 143 +++++++++++++-- convex/tickets.ts | 18 +- src/app/api/notifications/send/route.ts | 168 ++++++++++++++++++ src/app/api/profile/avatar/route.ts | 88 +++++++++ src/app/login/login-page-client.tsx | 29 ++- .../recuperar/forgot-password-page-client.tsx | 29 ++- .../reset-password-page-client.tsx | 29 ++- src/components/settings/settings-content.tsx | 98 ++++++++-- 8 files changed, 543 insertions(+), 59 deletions(-) create mode 100644 src/app/api/notifications/send/route.ts create mode 100644 src/app/api/profile/avatar/route.ts diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 4e3b540..2116cfd 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -8,6 +8,45 @@ import { v } from "convex/values" import { renderSimpleNotificationEmailHtml } from "./reactEmail" import { buildBaseUrl } from "./url" +// API do Next.js para verificar preferências +async function sendViaNextApi(params: { + type: string + to: { email: string; name?: string; userId?: string } + subject: string + data: Record + tenantId?: string +}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> { + const baseUrl = buildBaseUrl() + const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET + + if (!token) { + console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente") + return { success: false, reason: "no_token" } + } + + try { + const response = await fetch(`${baseUrl}/api/notifications/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + const error = await response.text() + console.error("[ticketNotifications] Erro na API:", error) + return { success: false, reason: "api_error" } + } + + return await response.json() + } catch (error) { + console.error("[ticketNotifications] Erro ao chamar API:", error) + return { success: false, reason: "fetch_error" } + } +} + function b64(input: string) { return Buffer.from(input, "utf8").toString("base64") } @@ -284,17 +323,15 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: export const sendTicketCreatedEmail = action({ args: { to: v.string(), + userId: v.optional(v.string()), + userName: v.optional(v.string()), ticketId: v.string(), reference: v.number(), subject: v.string(), priority: v.string(), + tenantId: v.optional(v.string()), }, - handler: async (_ctx, { to, ticketId, reference, subject, priority }) => { - const smtp = buildSmtpConfig() - if (!smtp) { - console.warn("SMTP not configured; skipping ticket created email") - return { skipped: true } - } + handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => { const baseUrl = buildBaseUrl() const url = `${baseUrl}/portal/tickets/${ticketId}` @@ -305,8 +342,34 @@ export const sendTicketCreatedEmail = action({ URGENT: "Urgente", } const priorityLabel = priorityLabels[priority] ?? priority - const mailSubject = `Novo chamado #${reference} aberto` + + // Tenta usar a API do Next.js para verificar preferências + const apiResult = await sendViaNextApi({ + type: "ticket_created", + to: { email: to, name: userName, userId }, + subject: mailSubject, + data: { + reference, + subject, + status: "Pendente", + priority: priorityLabel, + viewUrl: url, + }, + tenantId, + }) + + if (apiResult.success || apiResult.skipped) { + return apiResult + } + + // Fallback: envia diretamente se a API falhar + const smtp = buildSmtpConfig() + if (!smtp) { + console.warn("SMTP not configured; skipping ticket created email") + return { skipped: true } + } + const html = await renderSimpleNotificationEmailHtml({ title: `Novo chamado #${reference} aberto`, message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`, @@ -321,22 +384,45 @@ export const sendTicketCreatedEmail = action({ export const sendPublicCommentEmail = action({ args: { to: v.string(), + userId: v.optional(v.string()), + userName: v.optional(v.string()), ticketId: v.string(), reference: v.number(), subject: v.string(), + tenantId: v.optional(v.string()), }, - handler: async (_ctx, { to, ticketId, reference, subject }) => { + handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => { + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Atualização no chamado #${reference}: ${subject}` + + // Tenta usar a API do Next.js para verificar preferências + const apiResult = await sendViaNextApi({ + type: "comment_public", + to: { email: to, name: userName, userId }, + subject: mailSubject, + data: { + reference, + subject, + viewUrl: url, + }, + tenantId, + }) + + if (apiResult.success || apiResult.skipped) { + return apiResult + } + + // Fallback: envia diretamente se a API falhar const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket comment email") return { skipped: true } } - const baseUrl = buildBaseUrl() - const url = `${baseUrl}/portal/tickets/${ticketId}` - const mailSubject = `Atualização no chamado #${reference}: ${subject}` + const html = await renderSimpleNotificationEmailHtml({ title: `Nova atualização no seu chamado #${reference}`, - message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`, + message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`, ctaLabel: "Abrir e responder", ctaUrl: url, }) @@ -348,22 +434,45 @@ export const sendPublicCommentEmail = action({ export const sendResolvedEmail = action({ args: { to: v.string(), + userId: v.optional(v.string()), + userName: v.optional(v.string()), ticketId: v.string(), reference: v.number(), subject: v.string(), + tenantId: v.optional(v.string()), }, - handler: async (_ctx, { to, ticketId, reference, subject }) => { + handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => { + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Seu chamado #${reference} foi encerrado` + + // Tenta usar a API do Next.js para verificar preferências + const apiResult = await sendViaNextApi({ + type: "ticket_resolved", + to: { email: to, name: userName, userId }, + subject: mailSubject, + data: { + reference, + subject, + viewUrl: url, + }, + tenantId, + }) + + if (apiResult.success || apiResult.skipped) { + return apiResult + } + + // Fallback: envia diretamente se a API falhar const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket resolution email") return { skipped: true } } - const baseUrl = buildBaseUrl() - const url = `${baseUrl}/portal/tickets/${ticketId}` - const mailSubject = `Seu chamado #${reference} foi encerrado` + const html = await renderSimpleNotificationEmailHtml({ title: `Chamado #${reference} encerrado`, - message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, + message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, ctaLabel: "Ver detalhes", ctaUrl: url, }) diff --git a/convex/tickets.ts b/convex/tickets.ts index 428fd74..f80342b 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -2464,10 +2464,13 @@ export const create = mutation({ if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, { to: requesterEmail, + userId: String(requester._id), + userName: requester.name ?? undefined, ticketId: String(id), reference: nextRef, subject, priority: args.priority, + tenantId: args.tenantId, }) } } @@ -2870,15 +2873,19 @@ export const addComment = mutation({ await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch }); // Notificação por e-mail: comentário público para o solicitante try { - const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email + const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined + const snapshotEmail = requesterSnapshot?.email if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { to: snapshotEmail, + userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, + userName: requesterSnapshot?.name ?? undefined, ticketId: String(ticketDoc._id), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", + tenantId: ticketDoc.tenantId, }) } } @@ -3146,16 +3153,21 @@ export async function resolveTicketHandler( // Notificação por e-mail: encerramento do chamado try { - const requesterDoc = await ctx.db.get(ticketDoc.requesterId) - const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null + const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null + const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined + const email = requesterDoc?.email || requesterSnapshot?.email || null + const userName = requesterDoc?.name || requesterSnapshot?.name || undefined if (email) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { to: email, + userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, + userName, ticketId: String(ticketId), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", + tenantId: ticketDoc.tenantId, }) } } diff --git a/src/app/api/notifications/send/route.ts b/src/app/api/notifications/send/route.ts new file mode 100644 index 0000000..e97572c --- /dev/null +++ b/src/app/api/notifications/send/route.ts @@ -0,0 +1,168 @@ +/** + * API de Envio de Notificações + * Chamada pelo Convex para enviar e-mails respeitando preferências do usuário + */ + +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { prisma } from "@/lib/prisma" +import { sendEmail, type NotificationType, type TemplateName, NOTIFICATION_TYPES } from "@/server/email" + +// Token de autenticação interna (deve ser o mesmo usado no Convex) +const INTERNAL_TOKEN = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET + +const sendNotificationSchema = z.object({ + type: z.enum([ + "ticket_created", + "ticket_assigned", + "ticket_resolved", + "ticket_reopened", + "ticket_status_changed", + "ticket_priority_changed", + "comment_public", + "comment_response", + "sla_at_risk", + "sla_breached", + "automation", + ]), + to: z.object({ + email: z.string().email(), + name: z.string().optional(), + userId: z.string().optional(), + }), + subject: z.string(), + data: z.record(z.any()), + tenantId: z.string().optional(), + skipPreferenceCheck: z.boolean().optional(), +}) + +async function shouldSendNotification( + userId: string | undefined, + notificationType: NotificationType | "automation", + tenantId?: string +): Promise { + // Automações sempre passam (são configuradas separadamente) + if (notificationType === "automation") return true + + // Se não tem userId, não pode verificar preferências + if (!userId) return true + + try { + const prefs = await prisma.notificationPreferences.findUnique({ + where: { userId }, + }) + + // Se não tem preferências, usa os defaults + if (!prefs) return true + + // Se e-mail está desabilitado globalmente + if (!prefs.emailEnabled) return false + + // Verifica se é um tipo obrigatório + const config = NOTIFICATION_TYPES[notificationType as NotificationType] + if (config?.required) return true + + // Verifica preferências por tipo + const typePrefs = prefs.typePreferences + ? JSON.parse(prefs.typePreferences as string) + : {} + + if (notificationType in typePrefs) { + return typePrefs[notificationType] !== false + } + + // Usa o default do tipo + return config?.defaultEnabled ?? true + } catch (error) { + console.error("[notifications/send] Erro ao verificar preferências:", error) + // Em caso de erro, envia a notificação + return true + } +} + +function getTemplateForType(type: string): string { + const templateMap: Record = { + ticket_created: "ticket_created", + ticket_assigned: "ticket_assigned", + ticket_resolved: "ticket_resolved", + ticket_reopened: "ticket_status", + ticket_status_changed: "ticket_status", + ticket_priority_changed: "ticket_status", + comment_public: "ticket_comment", + comment_response: "ticket_comment", + sla_at_risk: "sla_warning", + sla_breached: "sla_breached", + automation: "automation", + } + return templateMap[type] ?? "simple_notification" +} + +export async function POST(request: NextRequest) { + try { + // Verifica autenticação + const authHeader = request.headers.get("authorization") + const token = authHeader?.replace("Bearer ", "") + + if (!INTERNAL_TOKEN || token !== INTERNAL_TOKEN) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = await request.json() + const parsed = sendNotificationSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: "Dados inválidos", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { type, to, subject, data, tenantId, skipPreferenceCheck } = parsed.data + + // Verifica preferências do usuário + if (!skipPreferenceCheck) { + const shouldSend = await shouldSendNotification( + to.userId, + type as NotificationType | "automation", + tenantId + ) + + if (!shouldSend) { + return NextResponse.json({ + success: true, + skipped: true, + reason: "user_preference_disabled", + }) + } + } + + // Envia o e-mail + const template = getTemplateForType(type) + const result = await sendEmail({ + to: { + email: to.email, + name: to.name, + userId: to.userId, + }, + subject, + template, + data, + notificationType: type === "automation" ? undefined : (type as NotificationType), + tenantId, + skipPreferenceCheck: true, // Já verificamos acima + }) + + return NextResponse.json({ + success: result.success, + skipped: result.skipped, + reason: result.reason, + }) + } catch (error) { + console.error("[notifications/send] Erro:", error) + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..fce3604 --- /dev/null +++ b/src/app/api/profile/avatar/route.ts @@ -0,0 +1,88 @@ +/** + * API de Upload de Avatar + * POST - Faz upload de uma nova foto de perfil + */ + +import { NextRequest, NextResponse } from "next/server" + +import { getServerSession } from "@/lib/auth-server" +import { prisma } from "@/lib/prisma" +import { createConvexClient } from "@/server/convex-client" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "Nenhum arquivo enviado" }, { status: 400 }) + } + + // Valida tipo + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json( + { error: "Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF." }, + { status: 400 } + ) + } + + // Valida tamanho + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "Arquivo muito grande. Máximo 5MB." }, + { status: 400 } + ) + } + + const convex = createConvexClient() + + // Gera URL de upload + const uploadUrl = await convex.action(api.files.generateUploadUrl, {}) + + // Faz upload do arquivo + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }) + + if (!uploadResponse.ok) { + console.error("[profile/avatar] Erro no upload:", await uploadResponse.text()) + return NextResponse.json({ error: "Erro ao fazer upload" }, { status: 500 }) + } + + const { storageId } = (await uploadResponse.json()) as { storageId: Id<"_storage"> } + + // Obtém URL pública do arquivo + const avatarUrl = await convex.action(api.files.getUrl, { storageId }) + + if (!avatarUrl) { + return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 }) + } + + // Atualiza o usuário no banco + await prisma.authUser.update({ + where: { id: session.user.id }, + data: { image: avatarUrl }, + }) + + return NextResponse.json({ + success: true, + avatarUrl, + }) + } catch (error) { + console.error("[profile/avatar] Erro:", error) + return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 }) + } +} diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index f932c65..ee1c8f2 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -54,12 +54,19 @@ export function LoginPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -81,8 +88,16 @@ export function LoginPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Bem-vindo de volta

+

+ Gerencie seus chamados e tickets de forma simples +

+
+
) diff --git a/src/app/recuperar/forgot-password-page-client.tsx b/src/app/recuperar/forgot-password-page-client.tsx index 46562aa..2ea330d 100644 --- a/src/app/recuperar/forgot-password-page-client.tsx +++ b/src/app/recuperar/forgot-password-page-client.tsx @@ -63,12 +63,19 @@ export function ForgotPasswordPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -99,8 +106,16 @@ export function ForgotPasswordPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Recuperar acesso

+

+ Enviaremos as instruções para seu e-mail +

+
+
) diff --git a/src/app/redefinir-senha/reset-password-page-client.tsx b/src/app/redefinir-senha/reset-password-page-client.tsx index 96122d0..4a6b83c 100644 --- a/src/app/redefinir-senha/reset-password-page-client.tsx +++ b/src/app/redefinir-senha/reset-password-page-client.tsx @@ -96,12 +96,19 @@ export function ResetPasswordPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -135,8 +142,16 @@ export function ResetPasswordPageClient() { Desenvolvido por Esdras Renan
-
- +
+ +
+
+

Nova senha

+

+ Crie uma senha segura para sua conta +

+
+
) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 7f8edd2..5f0eca2 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -1,11 +1,10 @@ "use client" -import { FormEvent, useMemo, useState } from "react" +import { FormEvent, useMemo, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" import { - Settings2, Share2, ShieldCheck, UserPlus, @@ -18,10 +17,10 @@ import { Mail, Key, User, - Building2, Shield, Clock, Camera, + Loader2, } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" @@ -55,11 +54,11 @@ const ROLE_LABELS: Record = { } const ROLE_COLORS: Record = { - admin: "bg-violet-100 text-violet-700 border-violet-200", - manager: "bg-blue-100 text-blue-700 border-blue-200", - agent: "bg-cyan-100 text-cyan-700 border-cyan-200", - collaborator: "bg-slate-100 text-slate-700 border-slate-200", - customer: "bg-slate-100 text-slate-700 border-slate-200", + admin: "bg-cyan-100 text-cyan-800 border-cyan-300", + manager: "bg-cyan-50 text-cyan-700 border-cyan-200", + agent: "bg-cyan-50 text-cyan-600 border-cyan-200", + collaborator: "bg-neutral-100 text-neutral-600 border-neutral-200", + customer: "bg-neutral-100 text-neutral-600 border-neutral-200", } const SETTINGS_ACTIONS: SettingsAction[] = [ @@ -326,6 +325,55 @@ function ProfileEditCard({ const [newPassword, setNewPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) + const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl) + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const fileInputRef = useRef(null) + + async function handleAvatarUpload(event: React.ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + + // Valida tamanho (5MB) + if (file.size > 5 * 1024 * 1024) { + toast.error("Arquivo muito grande. Máximo 5MB.") + return + } + + // Valida tipo + if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) { + toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.") + return + } + + setIsUploadingAvatar(true) + try { + const formData = new FormData() + formData.append("file", file) + + const res = await fetch("/api/profile/avatar", { + method: "POST", + body: formData, + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Erro ao fazer upload" })) + throw new Error(data.error || "Erro ao fazer upload") + } + + const data = await res.json() + setLocalAvatarUrl(data.avatarUrl) + toast.success("Foto atualizada com sucesso!") + } catch (error) { + console.error("Erro ao fazer upload:", error) + toast.error(error instanceof Error ? error.message : "Erro ao fazer upload da foto") + } finally { + setIsUploadingAvatar(false) + // Limpa o input para permitir reselecionar o mesmo arquivo + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } const hasChanges = useMemo(() => { const nameChanged = editName.trim() !== name @@ -387,25 +435,39 @@ function ProfileEditCard({ } return ( - - -
+ + {/* Header com degradê */} +
+ +
- - - + + + {initials} +
-
+
{name || "Usuário"} {email}
From b614fcd7dc9480c2bc3210112465b5a6fd5a57eb Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 11:13:00 -0300 Subject: [PATCH 006/182] style: melhora layout de login e settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Badge Helpdesk preta com texto branco - Texto maior no painel direito das páginas de auth - Badge de papel preta em settings - Adiciona descrição na seção Segurança - Espaçamento entre título e campos no formulário de login - Autocomplete nos inputs de senha - Link de notificações funcional no menu do usuário - Fallback do avatar com fundo cinza e texto preto 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/app/login/login-page-client.tsx | 8 ++++---- .../recuperar/forgot-password-page-client.tsx | 8 ++++---- .../reset-password-page-client.tsx | 8 ++++---- src/components/login-form.tsx | 2 +- src/components/nav-user.tsx | 19 ++++++++++++++----- src/components/settings/settings-content.tsx | 19 ++++++++++++------- 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index ee1c8f2..ecf5bc9 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -60,7 +60,7 @@ export function LoginPageClient() { Raven - + Helpdesk
@@ -90,10 +90,10 @@ export function LoginPageClient() {
-
+
-

Bem-vindo de volta

-

+

Bem-vindo de volta

+

Gerencie seus chamados e tickets de forma simples

diff --git a/src/app/recuperar/forgot-password-page-client.tsx b/src/app/recuperar/forgot-password-page-client.tsx index 2ea330d..74fd0c2 100644 --- a/src/app/recuperar/forgot-password-page-client.tsx +++ b/src/app/recuperar/forgot-password-page-client.tsx @@ -69,7 +69,7 @@ export function ForgotPasswordPageClient() { Raven - + Helpdesk
@@ -108,10 +108,10 @@ export function ForgotPasswordPageClient() {
-
+
-

Recuperar acesso

-

+

Recuperar acesso

+

Enviaremos as instruções para seu e-mail

diff --git a/src/app/redefinir-senha/reset-password-page-client.tsx b/src/app/redefinir-senha/reset-password-page-client.tsx index 4a6b83c..807351f 100644 --- a/src/app/redefinir-senha/reset-password-page-client.tsx +++ b/src/app/redefinir-senha/reset-password-page-client.tsx @@ -102,7 +102,7 @@ export function ResetPasswordPageClient() { Raven - + Helpdesk
@@ -144,10 +144,10 @@ export function ResetPasswordPageClient() {
-
+
-

Nova senha

-

+

Nova senha

+

Crie uma senha segura para sua conta

diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 7e05e36..995e873 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -64,7 +64,7 @@ export function LoginForm({ className, callbackUrl, disabled = false, ...props } {...props} > -
+

Acesse sua conta

Informe seu e-mail corporativo e senha para continuar atendendo os chamados. diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 17235b4..1b595b7 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -70,6 +70,10 @@ export function NavUser({ user }: NavUserProps) { router.push("/settings") }, [router]) + const handleNotifications = useCallback(() => { + router.push("/settings/notifications") + }, [router]) + const handleSignOut = useCallback(async () => { if (isSigningOut) return setIsSigningOut(true) @@ -94,9 +98,9 @@ export function NavUser({ user }: NavUserProps) { size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + - {initials} + {initials}

{displayName} @@ -117,7 +121,7 @@ export function NavUser({ user }: NavUserProps) {
- {initials} + {initials}
{displayName} @@ -138,9 +142,14 @@ export function NavUser({ user }: NavUserProps) { Meu perfil - + { + event.preventDefault() + handleNotifications() + }} + > - Notificações (em breve) + Notificações {!isDesktopShell && !isMachineSession ? ( diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 5f0eca2..51beb5c 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -54,11 +54,11 @@ const ROLE_LABELS: Record = { } const ROLE_COLORS: Record = { - admin: "bg-cyan-100 text-cyan-800 border-cyan-300", - manager: "bg-cyan-50 text-cyan-700 border-cyan-200", - agent: "bg-cyan-50 text-cyan-600 border-cyan-200", - collaborator: "bg-neutral-100 text-neutral-600 border-neutral-200", - customer: "bg-neutral-100 text-neutral-600 border-neutral-200", + admin: "bg-neutral-900 text-white border-neutral-900", + manager: "bg-neutral-800 text-white border-neutral-800", + agent: "bg-neutral-700 text-white border-neutral-700", + collaborator: "bg-neutral-600 text-white border-neutral-600", + customer: "bg-neutral-500 text-white border-neutral-500", } const SETTINGS_ACTIONS: SettingsAction[] = [ @@ -240,11 +240,14 @@ export function SettingsContent() { - + Segurança + + Gerencie a segurança da sua conta + + {localAvatarUrl && ( + + )} +
)} - +
{name || "Usuário"} From 2c21daee790da91a24b773c24f79845aa04c4810 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 11:25:25 -0300 Subject: [PATCH 009/182] =?UTF-8?q?fix(profile):=20corrige=20persist=C3=AA?= =?UTF-8?q?ncia=20do=20avatar=20e=20melhora=20fluxo=20de=20salvamento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Corrige campo de avatar na API (avatarUrl ao invés de image) - Altera fluxo para salvar foto apenas ao clicar em "Salvar alterações" - Adiciona preview local antes do upload definitivo - Ajusta shader para preencher bordas arredondadas do card 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/app/api/profile/avatar/route.ts | 4 +- src/components/settings/settings-content.tsx | 161 +++++++++++-------- 2 files changed, 92 insertions(+), 73 deletions(-) diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts index 5404823..d6e980c 100644 --- a/src/app/api/profile/avatar/route.ts +++ b/src/app/api/profile/avatar/route.ts @@ -75,7 +75,7 @@ export async function POST(request: NextRequest) { // Atualiza o usuário no banco await prisma.authUser.update({ where: { id: session.user.id }, - data: { image: avatarUrl }, + data: { avatarUrl }, }) return NextResponse.json({ @@ -99,7 +99,7 @@ export async function DELETE() { // Remove a imagem do usuário (volta ao padrão) await prisma.authUser.update({ where: { id: session.user.id }, - data: { image: null }, + data: { avatarUrl: null }, }) return NextResponse.json({ diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index f78e502..9f748a5 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -336,10 +336,15 @@ function ProfileEditCard({ const [confirmPassword, setConfirmPassword] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl) - const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const [pendingAvatarFile, setPendingAvatarFile] = useState(null) + const [pendingAvatarPreview, setPendingAvatarPreview] = useState(null) + const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false) const fileInputRef = useRef(null) - async function handleAvatarUpload(event: React.ChangeEvent) { + // URL de exibição: preview pendente > URL atual (se não marcado para remoção) + const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl) + + function handleAvatarSelect(event: React.ChangeEvent) { const file = event.target.files?.[0] if (!file) return @@ -355,57 +360,28 @@ function ProfileEditCard({ return } - setIsUploadingAvatar(true) - try { - const formData = new FormData() - formData.append("file", file) + // Cria preview local + const previewUrl = URL.createObjectURL(file) + setPendingAvatarFile(file) + setPendingAvatarPreview(previewUrl) + setPendingRemoveAvatar(false) - const res = await fetch("/api/profile/avatar", { - method: "POST", - body: formData, - }) - - if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Erro ao fazer upload" })) - throw new Error(data.error || "Erro ao fazer upload") - } - - const data = await res.json() - setLocalAvatarUrl(data.avatarUrl) - toast.success("Foto atualizada com sucesso!") - } catch (error) { - console.error("Erro ao fazer upload:", error) - toast.error(error instanceof Error ? error.message : "Erro ao fazer upload da foto") - } finally { - setIsUploadingAvatar(false) - // Limpa o input para permitir reselecionar o mesmo arquivo - if (fileInputRef.current) { - fileInputRef.current.value = "" - } + // Limpa o input para permitir reselecionar o mesmo arquivo + if (fileInputRef.current) { + fileInputRef.current.value = "" } } - async function handleRemoveAvatar() { - if (!localAvatarUrl) return - - setIsUploadingAvatar(true) - try { - const res = await fetch("/api/profile/avatar", { - method: "DELETE", - }) - - if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Erro ao remover foto" })) - throw new Error(data.error || "Erro ao remover foto") - } - - setLocalAvatarUrl(null) - toast.success("Foto removida com sucesso!") - } catch (error) { - console.error("Erro ao remover foto:", error) - toast.error(error instanceof Error ? error.message : "Erro ao remover foto") - } finally { - setIsUploadingAvatar(false) + function handleRemoveAvatarClick() { + // Limpa preview pendente se houver + if (pendingAvatarPreview) { + URL.revokeObjectURL(pendingAvatarPreview) + } + setPendingAvatarFile(null) + setPendingAvatarPreview(null) + // Marca para remoção apenas se já tiver avatar salvo + if (localAvatarUrl) { + setPendingRemoveAvatar(true) } } @@ -413,8 +389,9 @@ function ProfileEditCard({ const nameChanged = editName.trim() !== name const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase() const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0 - return nameChanged || emailChanged || passwordChanged - }, [editName, name, editEmail, email, newPassword, confirmPassword]) + const avatarChanged = pendingAvatarFile !== null || pendingRemoveAvatar + return nameChanged || emailChanged || passwordChanged || avatarChanged + }, [editName, name, editEmail, email, newPassword, confirmPassword, pendingAvatarFile, pendingRemoveAvatar]) async function handleSubmit(event: FormEvent) { event.preventDefault() @@ -442,27 +419,69 @@ function ProfileEditCard({ setIsSubmitting(true) try { - const res = await fetch("/api/portal/profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" })) - const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil" - toast.error(message) - return + // Processa avatar primeiro + if (pendingAvatarFile) { + const formData = new FormData() + formData.append("file", pendingAvatarFile) + + const avatarRes = await fetch("/api/profile/avatar", { + method: "POST", + body: formData, + }) + + if (!avatarRes.ok) { + const data = await avatarRes.json().catch(() => ({ error: "Erro ao fazer upload" })) + throw new Error(data.error || "Erro ao fazer upload da foto") + } + + const avatarData = await avatarRes.json() + setLocalAvatarUrl(avatarData.avatarUrl) + + // Limpa preview + if (pendingAvatarPreview) { + URL.revokeObjectURL(pendingAvatarPreview) + } + setPendingAvatarFile(null) + setPendingAvatarPreview(null) + } else if (pendingRemoveAvatar) { + const avatarRes = await fetch("/api/profile/avatar", { + method: "DELETE", + }) + + if (!avatarRes.ok) { + const data = await avatarRes.json().catch(() => ({ error: "Erro ao remover foto" })) + throw new Error(data.error || "Erro ao remover foto") + } + + setLocalAvatarUrl(null) + setPendingRemoveAvatar(false) } - const data = (await res.json().catch(() => null)) as { email?: string } | null - if (data?.email) { - setEditEmail(data.email) + + // Processa outros dados do perfil se houver + if (Object.keys(payload).length > 0) { + const res = await fetch("/api/portal/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" })) + const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil" + toast.error(message) + return + } + const data = (await res.json().catch(() => null)) as { email?: string } | null + if (data?.email) { + setEditEmail(data.email) + } } + setNewPassword("") setConfirmPassword("") toast.success("Dados atualizados com sucesso!") } catch (error) { console.error("Falha ao atualizar perfil", error) - toast.error("Não foi possível atualizar o perfil.") + toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.") } finally { setIsSubmitting(false) } @@ -471,14 +490,14 @@ function ProfileEditCard({ return ( {/* Header com shader animado */} -
+
- + {initials} @@ -487,11 +506,11 @@ function ProfileEditCard({ ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" - onChange={handleAvatarUpload} + onChange={handleAvatarSelect} className="hidden" />
- {isUploadingAvatar ? ( + {isSubmitting ? ( ) : (
@@ -503,11 +522,11 @@ function ProfileEditCard({ > - {localAvatarUrl && ( + {(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && ( ) : null} - {canEdit && !isResolved ? ( + {canEdit && !isResolved && canToggleAll ? ( ) : null} {canEdit && !isResolved && (templates ?? []).length > 0 ? ( From 95ab1b5f0c7c4e7685d25aaed32fa71bcca89e6d Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 12:03:40 -0300 Subject: [PATCH 011/182] feat(chat): adiciona interface de lista de chats estilo WhatsApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cria ChatSessionList e ChatSessionItem para listar sessões ativas - Refatora ChatWidget para usar viewMode (list/chat) - Ordena por não lidos primeiro, depois por última atividade - Adiciona botão de voltar quando há múltiplos chats - Persiste viewMode no localStorage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/chat/chat-session-item.tsx | 107 ++++++++++ src/components/chat/chat-session-list.tsx | 112 ++++++++++ src/components/chat/chat-widget.tsx | 247 ++++++++++++---------- 3 files changed, 359 insertions(+), 107 deletions(-) create mode 100644 src/components/chat/chat-session-item.tsx create mode 100644 src/components/chat/chat-session-list.tsx diff --git a/src/components/chat/chat-session-item.tsx b/src/components/chat/chat-session-item.tsx new file mode 100644 index 0000000..9abd94c --- /dev/null +++ b/src/components/chat/chat-session-item.tsx @@ -0,0 +1,107 @@ +"use client" + +import { cn } from "@/lib/utils" +import { MessageCircle, WifiOff } from "lucide-react" + +type ChatSession = { + ticketId: string + ticketRef: number + ticketSubject: string + sessionId: string + agentId: string + unreadCount: number + lastActivityAt: number + machineHostname?: string | null + machineOnline?: boolean +} + +type ChatSessionItemProps = { + session: ChatSession + isActive?: boolean + onClick: () => void +} + +function formatTime(timestamp: number) { + const now = Date.now() + const diff = now - timestamp + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + const days = Math.floor(diff / 86400000) + + if (minutes < 1) return "Agora" + if (minutes < 60) return `${minutes}min` + if (hours < 24) return `${hours}h` + if (days === 1) return "Ontem" + + return new Date(timestamp).toLocaleDateString("pt-BR", { + day: "2-digit", + month: "2-digit", + }) +} + +export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) { + const hasUnread = session.unreadCount > 0 + + return ( + + ) +} diff --git a/src/components/chat/chat-session-list.tsx b/src/components/chat/chat-session-list.tsx new file mode 100644 index 0000000..e84f592 --- /dev/null +++ b/src/components/chat/chat-session-list.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useMemo } from "react" +import { MessageCircle, X } from "lucide-react" +import { ChatSessionItem } from "./chat-session-item" + +type ChatSession = { + ticketId: string + ticketRef: number + ticketSubject: string + sessionId: string + agentId: string + unreadCount: number + lastActivityAt: number + machineHostname?: string | null + machineOnline?: boolean +} + +type ChatSessionListProps = { + sessions: ChatSession[] + activeTicketId?: string | null + onSelectSession: (ticketId: string) => void + onClose: () => void + onMinimize: () => void +} + +export function ChatSessionList({ + sessions, + activeTicketId, + onSelectSession, + onClose, + onMinimize, +}: ChatSessionListProps) { + // Ordenar: nao lidos primeiro, depois por ultima atividade (desc) + const sortedSessions = useMemo(() => { + return [...sessions].sort((a, b) => { + // Nao lidos primeiro + if (a.unreadCount > 0 && b.unreadCount === 0) return -1 + if (a.unreadCount === 0 && b.unreadCount > 0) return 1 + // Depois por ultima atividade (mais recente primeiro) + return b.lastActivityAt - a.lastActivityAt + }) + }, [sessions]) + + const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0) + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Chats

+

+ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} + {totalUnread > 0 && ( + + ({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""}) + + )} +

+
+
+
+ + +
+
+ + {/* Lista de sessoes */} +
+ {sortedSessions.length === 0 ? ( +
+
+ +
+

Nenhum chat ativo

+

+ Inicie um chat em um ticket para comecar +

+
+ ) : ( + sortedSessions.map((session) => ( + onSelectSession(session.ticketId)} + /> + )) + )} +
+
+ ) +} diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 5083ab5..ece49d7 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -8,13 +8,6 @@ import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { cn } from "@/lib/utils" import { toast } from "sonner" import { @@ -24,6 +17,7 @@ import { Minimize2, User, ChevronDown, + ChevronLeft, WifiOff, XCircle, Paperclip, @@ -34,16 +28,20 @@ import { Eye, Check, } from "lucide-react" +import { ChatSessionList } from "./chat-session-list" const MAX_MESSAGE_LENGTH = 4000 const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB const MAX_ATTACHMENTS = 5 const STORAGE_KEY = "chat-widget-state" +type ViewMode = "list" | "chat" + type ChatWidgetState = { isOpen: boolean isMinimized: boolean activeTicketId: string | null + viewMode: ViewMode } function formatTime(timestamp: number) { @@ -315,6 +313,17 @@ export function ChatWidget() { } catch {} return null }) + const [viewMode, setViewMode] = useState(() => { + if (typeof window === "undefined") return "list" + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) { + const state = JSON.parse(saved) as ChatWidgetState + return state.viewMode ?? "list" + } + } catch {} + return "list" + }) const [draft, setDraft] = useState("") const [isSending, setIsSending] = useState(false) const [isEndingChat, setIsEndingChat] = useState(false) @@ -369,6 +378,7 @@ export function ChatWidget() { const state = JSON.parse(event.newValue) as ChatWidgetState setIsOpen(state.isOpen) setIsMinimized(state.isMinimized) + setViewMode(state.viewMode ?? "list") if (state.activeTicketId) { setActiveTicketId(state.activeTicketId) } @@ -387,20 +397,32 @@ export function ChatWidget() { isOpen, isMinimized, activeTicketId, + viewMode, } // Salvar no localStorage (isso dispara evento storage em outras abas automaticamente) try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) } catch {} - }, [isOpen, isMinimized, activeTicketId]) + }, [isOpen, isMinimized, activeTicketId, viewMode]) - // Auto-selecionar primeira sessão se nenhuma selecionada + // Auto-selecionar modo e sessao baseado na quantidade de sessoes useEffect(() => { - if (!activeTicketId && activeSessions && activeSessions.length > 0) { + if (!activeSessions) return + + if (activeSessions.length === 0) { + // Sem sessoes, limpar estado + setActiveTicketId(null) + setViewMode("list") + } else if (activeSessions.length === 1) { + // Apenas 1 sessao, ir direto para chat setActiveTicketId(activeSessions[0].ticketId) + setViewMode("chat") + } else if (!activeTicketId) { + // Multiplas sessoes mas nenhuma selecionada, mostrar lista + setViewMode("list") } - }, [activeTicketId, activeSessions]) + }, [activeSessions, activeTicketId]) // Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat. // Nao roda na montagem inicial para nao sobrescrever o estado do localStorage. @@ -648,6 +670,16 @@ export function ChatWidget() { } } + // Handlers para navegacao lista/chat + const handleSelectSession = (ticketId: string) => { + setActiveTicketId(ticketId) + setViewMode("chat") + } + + const handleBackToList = () => { + setViewMode("list") + } + // Nao mostrar se esta no Tauri (usa o chat nativo) if (isTauriContext) return null @@ -670,106 +702,105 @@ export function ChatWidget() { {/* Widget aberto */} {isOpen && !isMinimized && (
- {/* Header - Estilo card da aplicação */} -
-
-
- -
-
-
-

Chat

- {/* Indicador online/offline */} - {liveChat?.hasMachine && ( - machineOnline ? ( - - - Online - - ) : ( - - - Offline - - ) - )} -
- {activeSession && ( -
- 1 ? ( + setIsOpen(false)} + onMinimize={() => setIsMinimized(true)} + /> + ) : ( + <> + {/* Header - Modo Chat */} +
+
+ {/* Botao voltar para lista (quando ha multiplas sessoes) */} + {activeSessions.length > 1 && ( + + )} +
+ +
+
+
+

+ {activeSession ? `#${activeSession.ticketRef}` : "Chat"} +

+ {/* Indicador online/offline */} + {liveChat?.hasMachine && ( + machineOnline ? ( + + + Online + + ) : ( + + + Offline + + ) + )} +
+ {activeSession && ( +
+ + {activeSession.ticketSubject} + + + {machineHostname && ( + + {machineHostname} + + )} +
)}
- )} +
+
+ {/* Botao encerrar chat */} + + + +
-
-
- {/* Botão encerrar chat */} - - - -
-
- - {/* Seletor de sessões (se mais de uma) */} - {activeSessions.length > 1 && ( -
- -
- )} {/* Aviso de máquina offline */} {liveChat?.hasMachine && !machineOnline && ( @@ -956,6 +987,8 @@ export function ChatWidget() { accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv" />
+ + )}
)} From 29fbbfaa26b640327b613714b6bbb796f22cbd3c Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 12:13:47 -0300 Subject: [PATCH 012/182] feat(desktop): adiciona hub de chats para multiplas sessoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cria ChatSessionList, ChatSessionItem e ChatHubWidget no desktop - Adiciona comandos Rust para gerenciar hub window - Quando ha multiplas sessoes, abre hub ao inves de janela individual - Hub lista todas as sessoes ativas com badge de nao lidos - Clicar em sessao abre/foca janela de chat especifica - Menu do tray abre hub quando ha multiplas sessoes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/src/chat.rs | 163 ++++++++++++++--- apps/desktop/src-tauri/src/lib.rs | 29 ++- apps/desktop/src/chat/ChatHubWidget.tsx | 212 ++++++++++++++++++++++ apps/desktop/src/chat/ChatSessionItem.tsx | 82 +++++++++ apps/desktop/src/chat/ChatSessionList.tsx | 99 ++++++++++ apps/desktop/src/chat/index.tsx | 13 +- 6 files changed, 560 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/chat/ChatHubWidget.tsx create mode 100644 apps/desktop/src/chat/ChatSessionItem.tsx create mode 100644 apps/desktop/src/chat/ChatSessionList.tsx diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 691ac99..e0cd7e5 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1000,37 +1000,58 @@ async fn process_chat_update( } } - // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. - let session_to_show = if best_delta > 0 { - best_session - } else { - current_sessions.iter().max_by(|a, b| { - a.unread_count - .cmp(&b.unread_count) - .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) - }) - }; - - // Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) - if let Some(session) = session_to_show { - let label = format!("chat-{}", session.ticket_id); - if let Some(window) = app.get_webview_window(&label) { - // Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida) - // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens - let _ = window.show(); - // Verificar se esta expandida (altura > 100px significa expandido) - // Se estiver expandida, NAO minimizar - usuario esta usando o chat - if let Ok(size) = window.inner_size() { - let is_expanded = size.height > 100; - if !is_expanded { - // Janela esta minimizada, manter minimizada - let _ = set_chat_minimized(app, &session.ticket_id, true); - } - // Se esta expandida, nao faz nada - deixa o usuario continuar usando - } + // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual + if current_sessions.len() > 1 { + // Multiplas sessoes - usar hub window + if app.get_webview_window(HUB_WINDOW_LABEL).is_none() { + // Hub nao existe - criar minimizado + let _ = open_hub_window(app); } else { - // Criar nova janela ja minimizada (menos intrusivo) - let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + // Hub ja existe - verificar se esta minimizado + if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { + let _ = hub.show(); + if let Ok(size) = hub.inner_size() { + if size.height < 100 { + // Esta minimizado, manter assim + let _ = set_hub_minimized(app, true); + } + } + } + } + } else { + // Uma sessao - abrir janela individual + // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. + let session_to_show = if best_delta > 0 { + best_session + } else { + current_sessions.iter().max_by(|a, b| { + a.unread_count + .cmp(&b.unread_count) + .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) + }) + }; + + // Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) + if let Some(session) = session_to_show { + let label = format!("chat-{}", session.ticket_id); + if let Some(window) = app.get_webview_window(&label) { + // Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida) + // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens + let _ = window.show(); + // Verificar se esta expandida (altura > 100px significa expandido) + // Se estiver expandida, NAO minimizar - usuario esta usando o chat + if let Ok(size) = window.inner_size() { + let is_expanded = size.height > 100; + if !is_expanded { + // Janela esta minimizada, manter minimizada + let _ = set_chat_minimized(app, &session.ticket_id, true); + } + // Se esta expandida, nao faz nada - deixa o usuario continuar usando + } + } else { + // Criar nova janela ja minimizada (menos intrusivo) + let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + } } } @@ -1201,3 +1222,85 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) } + +// ============================================================================ +// HUB WINDOW MANAGEMENT (Lista de todas as sessoes) +// ============================================================================ + +const HUB_WINDOW_LABEL: &str = "chat-hub"; + +pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + open_hub_window_with_state(app, true) // Por padrao abre minimizada +} + +fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> { + // Verificar se ja existe + if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + // Dimensoes baseadas no estado inicial + let (width, height) = if start_minimized { + (200.0, 52.0) // Tamanho minimizado (chip) + } else { + (380.0, 480.0) // Tamanho expandido (lista) + }; + + // Posicionar no canto inferior direito + let (x, y) = resolve_chat_window_position(app, None, width, height); + + // URL para modo hub + let url_path = "index.html?view=chat&hub=true"; + + WebviewWindowBuilder::new( + app, + HUB_WINDOW_LABEL, + WebviewUrl::App(url_path.into()), + ) + .title("Chats de Suporte") + .inner_size(width, height) + .min_inner_size(200.0, 52.0) + .position(x, y) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .focused(true) + .visible(true) + .build() + .map_err(|e| e.to_string())?; + + // Reaplica layout/posicao + let _ = set_hub_minimized(app, start_minimized); + + crate::log_info!("Hub window aberta (minimizada={})", start_minimized); + Ok(()) +} + +pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { + window.close().map_err(|e| e.to_string())?; + } + Ok(()) +} + +pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> { + let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?; + + let (width, height) = if minimized { + (200.0, 52.0) // Chip minimizado + } else { + (380.0, 480.0) // Lista expandida + }; + + let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); + + window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; + window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; + + crate::log_info!("Hub -> minimized={}", minimized); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2b3d54b..b059391 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -429,6 +429,21 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool) chat::set_chat_minimized(&app, &ticket_id, minimized) } +#[tauri::command] +fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> { + chat::open_hub_window(&app) +} + +#[tauri::command] +fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> { + chat::close_hub_window(&app) +} + +#[tauri::command] +fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> { + chat::set_hub_minimized(&app, minimized) +} + // ============================================================================ // Handler de Deep Link (raven://) // ============================================================================ @@ -598,7 +613,11 @@ pub fn run() { open_chat_window, close_chat_window, minimize_chat_window, - set_chat_minimized + set_chat_minimized, + // Hub commands + open_hub_window, + close_hub_window, + set_hub_minimized ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -680,7 +699,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { // Abrir janela de chat se houver sessao ativa if let Some(chat_runtime) = tray.app_handle().try_state::() { let sessions = chat_runtime.get_sessions(); - if let Some(session) = sessions.first() { + if sessions.len() > 1 { + // Multiplas sessoes - abrir hub + if let Err(e) = chat::open_hub_window(tray.app_handle()) { + log_error!("Falha ao abrir hub de chat: {e}"); + } + } else if let Some(session) = sessions.first() { + // Uma sessao - abrir diretamente if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) { log_error!("Falha ao abrir janela de chat: {e}"); } diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx new file mode 100644 index 0000000..464d48c --- /dev/null +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import { Loader2, MessageCircle, ChevronUp } from "lucide-react" +import { ChatSessionList } from "./ChatSessionList" +import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types" +import { getMachineStoreConfig } from "./machineStore" + +/** + * Hub Widget - Lista todas as sessoes de chat ativas + * Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket + */ +export function ChatHubWidget() { + const [sessions, setSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado + + const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) + + const ensureConfig = useCallback(async () => { + const cfg = configRef.current ?? (await getMachineStoreConfig()) + configRef.current = cfg + return cfg + }, []) + + // Buscar sessoes do backend + const loadSessions = useCallback(async () => { + try { + const cfg = await ensureConfig() + const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: cfg.token }), + }) + + if (!response.ok) { + throw new Error(`Falha ao buscar sessoes: ${response.status}`) + } + + const data = await response.json() as { sessions: ChatSession[] } + setSessions(data.sessions || []) + setError(null) + } catch (err) { + console.error("Erro ao carregar sessoes:", err) + setError(err instanceof Error ? err.message : "Erro desconhecido") + } finally { + setIsLoading(false) + } + }, [ensureConfig]) + + // Carregar sessoes na montagem + useEffect(() => { + loadSessions() + }, [loadSessions]) + + // Escutar eventos de atualizacao + useEffect(() => { + const unlisteners: (() => void)[] = [] + + // Quando nova sessao inicia + listen("raven://chat/session-started", () => { + loadSessions() + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando sessao encerra + listen("raven://chat/session-ended", () => { + loadSessions() + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando contador de nao lidos muda + listen("raven://chat/unread-update", (event) => { + setSessions(event.payload.sessions || []) + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando nova mensagem chega + listen("raven://chat/new-message", (event) => { + setSessions(event.payload.sessions || []) + }).then((unlisten) => unlisteners.push(unlisten)) + + return () => { + unlisteners.forEach((unlisten) => unlisten()) + } + }, [loadSessions]) + + // Sincronizar estado minimizado com tamanho da janela + useEffect(() => { + const mountTime = Date.now() + const STABILIZATION_DELAY = 500 + + const handler = () => { + if (Date.now() - mountTime < STABILIZATION_DELAY) { + return + } + const h = window.innerHeight + setIsMinimized(h < 100) + } + window.addEventListener("resize", handler) + return () => window.removeEventListener("resize", handler) + }, []) + + const handleSelectSession = async (ticketId: string, ticketRef: number) => { + try { + await invoke("open_chat_window", { ticketId, ticketRef }) + } catch (err) { + console.error("Erro ao abrir janela de chat:", err) + } + } + + const handleMinimize = async () => { + setIsMinimized(true) + try { + await invoke("set_hub_minimized", { minimized: true }) + } catch (err) { + console.error("Erro ao minimizar hub:", err) + } + } + + const handleExpand = async () => { + setIsMinimized(false) + try { + await invoke("set_hub_minimized", { minimized: false }) + } catch (err) { + console.error("Erro ao expandir hub:", err) + } + } + + const handleClose = () => { + invoke("close_hub_window") + } + + const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0) + + // Loading + if (isLoading) { + return ( +
+
+ + Carregando... +
+
+ ) + } + + // Erro + if (error) { + return ( +
+ +
+ ) + } + + // Sem sessoes ativas - mostrar chip cinza + if (sessions.length === 0) { + return ( +
+
+ + Sem chats +
+
+ ) + } + + // Minimizado - mostrar chip com contador + if (isMinimized) { + return ( +
+ +
+ ) + } + + // Expandido - mostrar lista + return ( +
+ +
+ ) +} diff --git a/apps/desktop/src/chat/ChatSessionItem.tsx b/apps/desktop/src/chat/ChatSessionItem.tsx new file mode 100644 index 0000000..d43447f --- /dev/null +++ b/apps/desktop/src/chat/ChatSessionItem.tsx @@ -0,0 +1,82 @@ +import { MessageCircle } from "lucide-react" +import type { ChatSession } from "./types" + +type ChatSessionItemProps = { + session: ChatSession + isActive?: boolean + onClick: () => void +} + +function formatTime(timestamp: number) { + const now = Date.now() + const diff = now - timestamp + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + const days = Math.floor(diff / 86400000) + + if (minutes < 1) return "Agora" + if (minutes < 60) return `${minutes}min` + if (hours < 24) return `${hours}h` + if (days === 1) return "Ontem" + + return new Date(timestamp).toLocaleDateString("pt-BR", { + day: "2-digit", + month: "2-digit", + }) +} + +export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) { + const hasUnread = session.unreadCount > 0 + + return ( + + ) +} diff --git a/apps/desktop/src/chat/ChatSessionList.tsx b/apps/desktop/src/chat/ChatSessionList.tsx new file mode 100644 index 0000000..2c43ed4 --- /dev/null +++ b/apps/desktop/src/chat/ChatSessionList.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react" +import { MessageCircle, X } from "lucide-react" +import { ChatSessionItem } from "./ChatSessionItem" +import type { ChatSession } from "./types" + +type ChatSessionListProps = { + sessions: ChatSession[] + onSelectSession: (ticketId: string, ticketRef: number) => void + onClose: () => void + onMinimize: () => void +} + +export function ChatSessionList({ + sessions, + onSelectSession, + onClose, + onMinimize, +}: ChatSessionListProps) { + // Ordenar: nao lidos primeiro, depois por ultima atividade (desc) + const sortedSessions = useMemo(() => { + return [...sessions].sort((a, b) => { + // Nao lidos primeiro + if (a.unreadCount > 0 && b.unreadCount === 0) return -1 + if (a.unreadCount === 0 && b.unreadCount > 0) return 1 + // Depois por ultima atividade (mais recente primeiro) + return b.lastActivityAt - a.lastActivityAt + }) + }, [sessions]) + + const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0) + + return ( +
+ {/* Header - arrastavel */} +
+
+
+ +
+
+

Chats

+

+ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} + {totalUnread > 0 && ( + + ({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""}) + + )} +

+
+
+
+ + +
+
+ + {/* Lista de sessoes */} +
+ {sortedSessions.length === 0 ? ( +
+
+ +
+

Nenhum chat ativo

+

+ Os chats aparecerao aqui quando iniciados +

+
+ ) : ( + sortedSessions.map((session) => ( + onSelectSession(session.ticketId, session.ticketRef)} + /> + )) + )} +
+
+ ) +} diff --git a/apps/desktop/src/chat/index.tsx b/apps/desktop/src/chat/index.tsx index 02e7f13..d20f75c 100644 --- a/apps/desktop/src/chat/index.tsx +++ b/apps/desktop/src/chat/index.tsx @@ -1,21 +1,22 @@ import { ChatWidget } from "./ChatWidget" +import { ChatHubWidget } from "./ChatHubWidget" export function ChatApp() { // Obter ticketId e ticketRef da URL const params = new URLSearchParams(window.location.search) const ticketId = params.get("ticketId") const ticketRef = params.get("ticketRef") + const isHub = params.get("hub") === "true" - if (!ticketId) { - return ( -
-

Erro: ticketId não fornecido

-
- ) + // Modo hub - lista de todas as sessoes + if (isHub || !ticketId) { + return } + // Modo chat - conversa de um ticket especifico return } export { ChatWidget } +export { ChatHubWidget } export * from "./types" From 86f818c6f38ac89bff45f4225365dfde58f31880 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:06:24 -0300 Subject: [PATCH 013/182] feat(chat): adiciona encerramento automatico por inatividade (12h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sessoes de chat sao encerradas apos 12 horas sem atividade - Criterios de encerramento automatico: 1. Maquina offline (5 min sem heartbeat) 2. Chat inativo (12 horas sem atividade) - NOVO 3. Ticket orfao (sem maquina vinculada) - Log detalhado com contagem por motivo de encerramento - Evento no timeline com reason "inatividade_chat" Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar o chat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- convex/liveChat.ts | 54 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 94109ef..d4bde33 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -763,15 +763,22 @@ export const getTicketChatHistory = query({ // Timeout de maquina offline: 5 minutos sem heartbeat const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 -// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron) -// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat -// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens +// Timeout de inatividade do chat: 12 horas sem atividade +// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar +const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000 + +// Mutation interna para encerrar sessões inativas (chamada pelo cron) +// Critérios de encerramento: +// 1. Máquina offline (5 min sem heartbeat) +// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online +// 3. Ticket órfão (sem máquina vinculada) export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { - console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") + console.log("cron: autoEndInactiveSessions iniciado") const now = Date.now() const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS + const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS // Limitar a 50 sessões por execução para evitar timeout do cron (30s) const maxSessionsPerRun = 50 @@ -784,6 +791,7 @@ export const autoEndInactiveSessions = mutation({ let endedCount = 0 let checkedCount = 0 + const reasons: Record = {} for (const session of activeSessions) { checkedCount++ @@ -812,6 +820,36 @@ export const autoEndInactiveSessions = mutation({ createdAt: now, }) endedCount++ + reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1 + continue + } + + // Verificar inatividade do chat (12 horas sem atividade) + // Isso tem prioridade sobre o status da máquina + const chatIsInactive = session.lastActivityAt < inactivityCutoff + if (chatIsInactive) { + await ctx.db.patch(session._id, { + status: "ENDED", + endedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId: session.ticketId, + type: "LIVE_CHAT_ENDED", + payload: { + sessionId: session._id, + agentId: session.agentId, + agentName: session.agentSnapshot?.name ?? "Sistema", + durationMs: now - session.startedAt, + startedAt: session.startedAt, + endedAt: now, + autoEnded: true, + reason: "inatividade_chat", + inactiveForMs: now - session.lastActivityAt, + }, + createdAt: now, + }) + endedCount++ + reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1 continue } @@ -819,7 +857,7 @@ export const autoEndInactiveSessions = mutation({ const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff - // Se máquina está online, manter sessão ativa + // Se máquina está online e chat está ativo, manter sessão if (machineIsOnline) { continue } @@ -849,10 +887,12 @@ export const autoEndInactiveSessions = mutation({ }) endedCount++ + reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1 } - console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) - return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } + const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ") + console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`) + return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun } }, }) From 6c6d53034fe9ee0cfde415730311f355dbb84802 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:19:08 -0300 Subject: [PATCH 014/182] style(chat): remove icone do header para mais espaco MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove o icone circular preto do header do chat quando esta dentro de uma conversa, mantendo mais espaco para informacoes do ticket. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/chat/chat-widget.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index ece49d7..2aa0b80 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -726,13 +726,10 @@ export function ChatWidget() { )} -
- -

- {activeSession ? `#${activeSession.ticketRef}` : "Chat"} + #{activeSession?.ticketRef ?? chatData?.ticketId?.slice(-4)}

{/* Indicador online/offline */} {liveChat?.hasMachine && ( From 973e3496e2e62c6a529c066b2f1e3535112d4680 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:19:56 -0300 Subject: [PATCH 015/182] =?UTF-8?q?fix(chat):=20corrige=20acentua=C3=A7?= =?UTF-8?q?=C3=A3o=20em=20"n=C3=A3o=20lida"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/chat/ChatSessionList.tsx | 2 +- src/components/chat/chat-session-list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/chat/ChatSessionList.tsx b/apps/desktop/src/chat/ChatSessionList.tsx index 2c43ed4..c98a90b 100644 --- a/apps/desktop/src/chat/ChatSessionList.tsx +++ b/apps/desktop/src/chat/ChatSessionList.tsx @@ -46,7 +46,7 @@ export function ChatSessionList({ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} {totalUnread > 0 && ( - ({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""}) + ({totalUnread} não lida{totalUnread !== 1 ? "s" : ""}) )}

diff --git a/src/components/chat/chat-session-list.tsx b/src/components/chat/chat-session-list.tsx index e84f592..478525c 100644 --- a/src/components/chat/chat-session-list.tsx +++ b/src/components/chat/chat-session-list.tsx @@ -58,7 +58,7 @@ export function ChatSessionList({ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} {totalUnread > 0 && ( - ({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""}) + ({totalUnread} não lida{totalUnread !== 1 ? "s" : ""}) )}

From ca59b6ed929041e41b0a35a5ce0dd154cf40c8c5 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:22:47 -0300 Subject: [PATCH 016/182] debug(chat): adiciona logs no clique da lista de sessoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs para debugar problema de clique não funcionando na lista de sessões do desktop. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/chat/ChatHubWidget.tsx | 5 ++++- apps/desktop/src/chat/ChatSessionItem.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx index 464d48c..48a6d33 100644 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -100,10 +100,13 @@ export function ChatHubWidget() { }, []) const handleSelectSession = async (ticketId: string, ticketRef: number) => { + console.log("[ChatHub] Selecionando sessao:", { ticketId, ticketRef }) try { await invoke("open_chat_window", { ticketId, ticketRef }) + console.log("[ChatHub] Janela aberta com sucesso") } catch (err) { - console.error("Erro ao abrir janela de chat:", err) + console.error("[ChatHub] Erro ao abrir janela de chat:", err) + alert(`Erro ao abrir chat: ${err}`) } } diff --git a/apps/desktop/src/chat/ChatSessionItem.tsx b/apps/desktop/src/chat/ChatSessionItem.tsx index d43447f..2c7bd77 100644 --- a/apps/desktop/src/chat/ChatSessionItem.tsx +++ b/apps/desktop/src/chat/ChatSessionItem.tsx @@ -28,10 +28,15 @@ function formatTime(timestamp: number) { export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) { const hasUnread = session.unreadCount > 0 + const handleClick = () => { + console.log("[ChatSessionItem] Clicado:", session.ticketId, session.ticketRef) + onClick() + } + return (
) : ( - sortedSessions.map((session) => ( - onSelectSession(session.ticketId, session.ticketRef)} - /> - )) + sortedSessions.map((session) => { + console.log("[ChatSessionList] Sessao:", session) + return ( + { + console.log("[ChatSessionList] Clicando na sessao:", session.ticketId, session.ticketRef) + onSelectSession(session.ticketId, session.ticketRef) + }} + /> + ) + }) )}
From 6b137434fe1f0025269cabaa68d79a7ed19a313a Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:51:34 -0300 Subject: [PATCH 018/182] fix(desktop): corrige permissoes e redimensionamento do chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona chat-hub explicitamente nas capabilities do Tauri - Adiciona .resizable(false) nas janelas de chat e hub - Corrige problema de comandos invoke nao funcionando no hub 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/capabilities/default.json | 2 +- apps/desktop/src-tauri/src/chat.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index e633b09..953687a 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all windows", - "windows": ["main", "chat-*"], + "windows": ["main", "chat-*", "chat-hub"], "permissions": [ "core:default", "core:event:default", diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index e0cd7e5..b625956 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1137,6 +1137,10 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r if let Some(window) = app.get_webview_window(&label) { window.show().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?; + // Expandir a janela se estiver minimizada (quando clicado na lista) + if !start_minimized { + let _ = set_chat_minimized(app, ticket_id, false); + } return Ok(()); } @@ -1165,6 +1169,7 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r .decorations(false) // Sem decoracoes nativas - usa header customizado .transparent(true) // Permite fundo transparente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente + .resizable(false) // Desabilitar redimensionamento manual .always_on_top(true) .skip_taskbar(true) .focused(true) @@ -1266,6 +1271,7 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> .decorations(false) .transparent(true) .shadow(false) + .resizable(false) // Desabilitar redimensionamento manual .always_on_top(true) .skip_taskbar(true) .focused(true) From c36e18117b3ac135a05b2a22422fd9653c5a2f99 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 13:53:53 -0300 Subject: [PATCH 019/182] fix(desktop): corrige problemas do chat (redimensionamento e cliques) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correções implementadas: 1. Adiciona .resizable(false) nas janelas de chat e hub para impedir redimensionamento manual 2. Corrige área clicável invisível quando minimizado (janela agora tem tamanho correto) 3. Corrige clique na lista de sessões para expandir janela quando clicado 4. Diferencia abertura automática (minimizada) de abertura manual (expandida) - Chat agora abre expandido quando clicado na lista do hub - Chat abre minimizado quando nova mensagem chega (menos intrusivo) - Janelas não permitem mais redimensionamento manual - Área clicável agora corresponde ao tamanho visual da janela 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/src/chat.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index b625956..fdd68ff 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1050,7 +1050,7 @@ async fn process_chat_update( } } else { // Criar nova janela ja minimizada (menos intrusivo) - let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true); } } } @@ -1125,8 +1125,8 @@ fn resolve_chat_window_position( (x, y) } -fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { - open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada +fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { + open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized) } /// Abre janela de chat com estado inicial de minimizacao configuravel @@ -1186,7 +1186,8 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r } pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { - open_chat_window_internal(app, ticket_id, ticket_ref) + // Quando chamado explicitamente (ex: clique no hub), abre expandida + open_chat_window_internal(app, ticket_id, ticket_ref, false) } pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { From d97e6927561df5c067a0667f58b8f61caa91555a Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:03:08 -0300 Subject: [PATCH 020/182] debug(chat): adiciona logs detalhados no invoke do hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/chat/ChatHubWidget.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx index 48a6d33..8722add 100644 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -102,11 +102,17 @@ export function ChatHubWidget() { const handleSelectSession = async (ticketId: string, ticketRef: number) => { console.log("[ChatHub] Selecionando sessao:", { ticketId, ticketRef }) try { - await invoke("open_chat_window", { ticketId, ticketRef }) - console.log("[ChatHub] Janela aberta com sucesso") + console.log("[ChatHub] Chamando invoke open_chat_window...") + const result = await invoke("open_chat_window", { ticketId, ticketRef }) + console.log("[ChatHub] Janela aberta com sucesso, result:", result) } catch (err) { - console.error("[ChatHub] Erro ao abrir janela de chat:", err) - alert(`Erro ao abrir chat: ${err}`) + console.error("[ChatHub] ERRO ao abrir janela de chat:", err) + console.error("[ChatHub] Tipo do erro:", typeof err, err) + // Tentar mostrar mais detalhes do erro + if (err instanceof Error) { + console.error("[ChatHub] Message:", err.message) + console.error("[ChatHub] Stack:", err.stack) + } } } From 05bc1cb7b4a18a10ff6a5fdd9e2557c0ec3d14ac Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:11:14 -0300 Subject: [PATCH 021/182] =?UTF-8?q?fix(chat-widget):=20corrige=20refer?= =?UTF-8?q?=C3=AAncia=20a=20chatData=20n=C3=A3o=20definida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/chat/chat-widget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 2aa0b80..49310b0 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -729,7 +729,7 @@ export function ChatWidget() {

- #{activeSession?.ticketRef ?? chatData?.ticketId?.slice(-4)} + #{activeSession?.ticketRef ?? activeTicketId?.slice(-4)}

{/* Indicador online/offline */} {liveChat?.hasMachine && ( From 915ca6d8ff88e143f2d167aa3129b8bfcdc097e0 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:13:28 -0300 Subject: [PATCH 022/182] fix(settings): corrige background animado do perfil preenchendo cantos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove rounded-t-2xl redundante que criava gap branco nos cantos superiores. O card pai já possui overflow-hidden com rounded-2xl. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/settings/settings-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 9f748a5..cc832cb 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -490,7 +490,7 @@ function ProfileEditCard({ return ( {/* Header com shader animado */} -
+
From 3b6b9dfeacbed888206a491e191781beeeb57980 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:24:01 -0300 Subject: [PATCH 023/182] =?UTF-8?q?fix(desktop):=20corrige=20par=C3=A2metr?= =?UTF-8?q?os=20invoke=20para=20snake=5Fcase=20no=20Tauri=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri 2 espera parâmetros em snake_case nos comandos Rust. Corrigido: ticketId -> ticket_id, ticketRef -> ticket_ref, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/chat/ChatHubWidget.tsx | 3 ++- apps/desktop/src/chat/ChatWidget.tsx | 18 +++++++++--------- apps/desktop/src/main.tsx | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx index 8722add..0bc8a51 100644 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -103,7 +103,8 @@ export function ChatHubWidget() { console.log("[ChatHub] Selecionando sessao:", { ticketId, ticketRef }) try { console.log("[ChatHub] Chamando invoke open_chat_window...") - const result = await invoke("open_chat_window", { ticketId, ticketRef }) + // Tauri 2 espera snake_case nos parametros + const result = await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef }) console.log("[ChatHub] Janela aberta com sucesso, result:", result) } catch (err) { console.error("[ChatHub] ERRO ao abrir janela de chat:", err) diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index e4964fc..7686b9d 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -292,7 +292,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { useEffect(() => { const prevHasSession = prevHasSessionRef.current if (prevHasSession && !hasSession) { - invoke("close_chat_window", { ticketId }).catch((err) => { + invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => { console.error("Erro ao fechar janela ao encerrar sessão:", err) }) } @@ -405,10 +405,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { for (const chunk of chunks) { await invoke("mark_chat_messages_read", { - baseUrl: cfg.apiBaseUrl, + base_url: cfg.apiBaseUrl, token: cfg.token, - ticketId, - messageIds: chunk, + ticket_id: ticketId, + message_ids: chunk, }) } @@ -657,9 +657,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const bodyToSend = messageText const cfg = await ensureConfig() await invoke("send_chat_message", { - baseUrl: cfg.apiBaseUrl, + base_url: cfg.apiBaseUrl, token: cfg.token, - ticketId, + ticket_id: ticketId, body: bodyToSend, attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, }) @@ -692,7 +692,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const handleMinimize = async () => { setIsMinimized(true) try { - await invoke("set_chat_minimized", { ticketId, minimized: true }) + await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: true }) } catch (err) { console.error("Erro ao minimizar janela:", err) } @@ -707,14 +707,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsMinimized(false) try { - await invoke("set_chat_minimized", { ticketId, minimized: false }) + await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: false }) } catch (err) { console.error("Erro ao expandir janela:", err) } } const handleClose = () => { - invoke("close_chat_window", { ticketId }) + invoke("close_chat_window", { ticket_id: ticketId }) } const handleKeyDown = (e: React.KeyboardEvent) => { diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index fe95677..b8b2df0 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1101,9 +1101,9 @@ const resolvedAppUrl = useMemo(() => { // Abre/minimiza chat quando aparecem novas não lidas if (hasSessions && totalUnread > prevUnread) { const session = payload.sessions[0] - invoke("open_chat_window", { ticketId: session.ticketId, ticketRef: session.ticketRef }).catch(console.error) + invoke("open_chat_window", { ticket_id: session.ticketId, ticket_ref: session.ticketRef }).catch(console.error) // Minimiza para não ser intrusivo - invoke("set_chat_minimized", { ticketId: session.ticketId, minimized: true }).catch(console.error) + invoke("set_chat_minimized", { ticket_id: session.ticketId, minimized: true }).catch(console.error) } prevUnread = totalUnread From 5f0c9b68c3d9cd674a02e4d1123644142c3077eb Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:24:08 -0300 Subject: [PATCH 024/182] fix(settings): aumenta altura do background animado do perfil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aumenta de h-20 para h-28 e ajusta margin-top do CardHeader para cobrir toda a área superior do card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/settings/settings-content.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index cc832cb..87ec795 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -489,11 +489,11 @@ function ProfileEditCard({ return ( - {/* Header com shader animado */} -
+ {/* Header com shader animado - altura suficiente para cobrir area superior */} +
- +
From 424927573c9f7e5218bec3f01b7c017894b4d5d0 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:34:41 -0300 Subject: [PATCH 025/182] fix(settings): posiciona background no topo absoluto do card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background agora usa position absolute para cobrir desde o topo do card, eliminando a faixa branca acima. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/settings/settings-content.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 87ec795..16ccd92 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -488,12 +488,13 @@ function ProfileEditCard({ } return ( - - {/* Header com shader animado - altura suficiente para cobrir area superior */} -
- + + {/* Background absoluto no topo do card */} +
+
- + {/* Conteudo com padding-top para ficar abaixo do background */} +
From 4ad0dc5c1e96107c12c85e85c1b7b4a042b01d45 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:38:58 -0300 Subject: [PATCH 026/182] style(settings): centraliza label "Alterar senha" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/settings/settings-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 16ccd92..d637d86 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -578,7 +578,7 @@ function ProfileEditCard({
-
) : ( - sortedSessions.map((session) => { - console.log("[ChatSessionList] Sessao:", session) - return ( + sortedSessions.map((session) => ( { - console.log("[ChatSessionList] Clicando na sessao:", session.ticketId, session.ticketRef) - onSelectSession(session.ticketId, session.ticketRef) - }} + onClick={() => onSelectSession(session.ticketId, session.ticketRef)} /> - ) - }) + )) )}
From c0e04213695122fc823b2ee439cd1274b59afae3 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Mon, 15 Dec 2025 14:47:22 -0300 Subject: [PATCH 028/182] =?UTF-8?q?style(settings):=20centraliza=20bot?= =?UTF-8?q?=C3=A3o=20Alterar=20senha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove justify-start para centralizar conteúdo como o botão Encerrar sessão. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/settings/settings-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index d637d86..a14448f 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -257,7 +257,7 @@ export function SettingsContent() { -
-