From 152550a9a081bb462acd472256214cf874c76a57 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 8 Oct 2025 23:07:49 -0300 Subject: [PATCH] feat: habilitar provisionamento desktop e rotas CORS --- apps/desktop/index.html | 20 +- apps/desktop/package.json | 3 +- apps/desktop/src-tauri/Cargo.lock | 442 +++++++++++++++++++++++- apps/desktop/src-tauri/Cargo.toml | 12 +- apps/desktop/src-tauri/src/agent.rs | 333 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 37 +- apps/desktop/src-tauri/tauri.conf.json | 19 +- apps/desktop/src/main.ts | 434 ++++++++++++++++++++++- apps/desktop/src/styles.css | 283 ++++++++++----- docs/plano-app-desktop-maquinas.md | 13 +- package.json | 4 +- pnpm-lock.yaml | 100 ++++++ pnpm-workspace.yaml | 1 + src/app/api/machines/heartbeat/route.ts | 23 +- src/app/api/machines/register/route.ts | 27 +- src/app/api/machines/sessions/route.ts | 83 ++--- src/app/machines/handshake/route.ts | 64 ++++ src/server/cors.ts | 38 ++ src/server/machines-session.ts | 81 +++++ 19 files changed, 1806 insertions(+), 211 deletions(-) create mode 100644 apps/desktop/src-tauri/src/agent.rs create mode 100644 src/app/machines/handshake/route.ts create mode 100644 src/server/cors.ts create mode 100644 src/server/machines-session.ts diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 19e0f95..043ef84 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -1,19 +1,23 @@ - + - Sistema de Chamados Desktop + Sistema de Chamados — Agente Desktop - -
-
-

Abrindo Sistema de Chamados…

-

Certifique-se de que o serviço web está disponível em VITE_APP_URL.

-
+
+
+
+

Sistema de Chamados

+

Agente desktop para provisionamento de máquinas

+
+
+
+

+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 39646fc..ea65d0b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-store": "^2" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 78653b9..7b5a8ac 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -60,11 +60,20 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" name = "appsdesktop" version = "0.1.0" dependencies = [ + "chrono", + "hostname", + "once_cell", + "parking_lot", + "reqwest", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-store", + "thiserror 1.0.69", + "tokio", ] [[package]] @@ -486,8 +495,10 @@ 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 0.2.1", ] @@ -593,6 +604,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -821,6 +851,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1230,8 +1266,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1241,9 +1279,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1436,6 +1476,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1509,6 +1560,23 @@ dependencies = [ "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.17" @@ -1947,6 +2015,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2102,6 +2176,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2822,6 +2905,61 @@ dependencies = [ "memchr", ] +[[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.3", + "lru-slab", + "rand 0.9.2", + "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.41" @@ -2862,6 +3000,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2882,6 +3030,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[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 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2900,6 +3058,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2924,6 +3091,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3007,16 +3194,21 @@ dependencies = [ "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", "tokio-util", "tower", "tower-http", @@ -3026,6 +3218,21 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "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]] @@ -3034,6 +3241,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3056,6 +3269,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3489,6 +3737,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3542,6 +3796,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3589,7 +3857,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3661,7 +3929,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3762,10 +4030,26 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.17", "url", - "windows", + "windows 0.61.3", "zbus", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.8.0" @@ -3788,7 +4072,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3814,7 +4098,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -3971,6 +4255,21 @@ dependencies = [ "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.47.1" @@ -3985,9 +4284,31 @@ dependencies = [ "pin-project-lite", "slab", "socket2", + "tokio-macros", "windows-sys 0.59.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[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 = "tokio-util" version = "0.7.16" @@ -4277,6 +4598,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[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" @@ -4501,6 +4828,16 @@ dependencies = [ "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 = "webkit2gtk" version = "2.0.1" @@ -4545,6 +4882,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.0" @@ -4553,10 +4899,10 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4577,7 +4923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ "thiserror 2.0.17", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4627,6 +4973,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4649,14 +5005,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4668,8 +5036,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4686,6 +5054,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4697,6 +5076,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4730,6 +5120,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4775,6 +5174,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[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" @@ -5085,7 +5493,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5237,6 +5645,12 @@ dependencies = [ "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.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a5b2d9f..7ef2c39 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,8 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["wry"] } tauri-plugin-opener = "2" +tauri-plugin-store = "2.4" serde = { version = "1", features = ["derive"] } serde_json = "1" - +sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +once_cell = "1.19" +thiserror = "1.0" +chrono = { version = "0.4", features = ["serde"] } +parking_lot = "0.12" +hostname = "0.4" diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs new file mode 100644 index 0000000..816de05 --- /dev/null +++ b/apps/desktop/src-tauri/src/agent.rs @@ -0,0 +1,333 @@ +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Serialize; +use sysinfo::{Networks, System}; +use tauri::async_runtime::{self, JoinHandle}; +use tokio::sync::Notify; + +#[derive(thiserror::Error, Debug)] +pub enum AgentError { + #[error("Falha ao obter hostname da máquina")] + Hostname, + #[error("Nenhum identificador de hardware disponível (MAC/serial)")] + MissingIdentifiers, + #[error("URL de API inválida")] + InvalidApiUrl, + #[error("Falha HTTP: {0}")] + Http(#[from] reqwest::Error), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineOs { + pub name: String, + pub version: Option, + pub architecture: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineMetrics { + pub collected_at: DateTime, + pub cpu_logical_cores: usize, + pub cpu_physical_cores: Option, + pub cpu_usage_percent: f32, + pub load_average_one: Option, + pub load_average_five: Option, + pub load_average_fifteen: Option, + pub memory_total_bytes: u64, + pub memory_used_bytes: u64, + pub memory_used_percent: f32, + pub uptime_seconds: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineInventory { + pub cpu_brand: Option, + pub host_identifier: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineProfile { + pub hostname: String, + pub os: MachineOs, + pub mac_addresses: Vec, + pub serial_numbers: Vec, + pub inventory: MachineInventory, + pub metrics: MachineMetrics, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HeartbeatPayload { + machine_token: String, + status: Option, + hostname: Option, + os: Option, + metrics: Option, + metadata: Option, +} + +fn collect_mac_addresses() -> Vec { + let mut macs = Vec::new(); + let mut networks = Networks::new(); + networks.refresh_list(); + networks.refresh(); + + for (_, data) in networks.iter() { + let bytes = data.mac_address().0; + if bytes.iter().all(|byte| *byte == 0) { + continue; + } + + let formatted = bytes + .iter() + .map(|byte| format!("{:02x}", byte)) + .collect::>() + .join(":"); + + if !macs.contains(&formatted) { + macs.push(formatted); + } + } + + macs +} + +fn collect_system() -> System { + let mut system = System::new_all(); + system.refresh_all(); + system +} + +fn collect_metrics(system: &System) -> MachineMetrics { + let collected_at = Utc::now(); + let total_memory = system.total_memory(); + let used_memory = system.used_memory(); + let memory_total_bytes = total_memory.saturating_mul(1024); + let memory_used_bytes = used_memory.saturating_mul(1024); + let memory_used_percent = if total_memory > 0 { + (used_memory as f32 / total_memory as f32) * 100.0 + } else { + 0.0 + }; + + let load = System::load_average(); + let cpu_usage_percent = system.global_cpu_usage(); + let cpu_logical_cores = system.cpus().len(); + let cpu_physical_cores = system.physical_core_count(); + + MachineMetrics { + collected_at, + cpu_logical_cores, + cpu_physical_cores, + cpu_usage_percent, + load_average_one: Some(load.one), + load_average_five: Some(load.five), + load_average_fifteen: Some(load.fifteen), + memory_total_bytes, + memory_used_bytes, + memory_used_percent, + uptime_seconds: System::uptime(), + } +} + +pub fn collect_profile() -> Result { + let hostname = hostname::get() + .map_err(|_| AgentError::Hostname)? + .to_string_lossy() + .trim() + .to_string(); + + let system = collect_system(); + + let os_name = System::name() + .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(); + + let mac_addresses = collect_mac_addresses(); + let serials: Vec = Vec::new(); + + if mac_addresses.is_empty() && serials.is_empty() { + return Err(AgentError::MissingIdentifiers); + } + + let metrics = collect_metrics(&system); + let cpu_brand = system + .cpus() + .first() + .map(|cpu| cpu.brand().to_string()) + .filter(|brand| !brand.trim().is_empty()); + + let inventory = MachineInventory { + cpu_brand, + host_identifier: serials.first().cloned(), + }; + + Ok(MachineProfile { + hostname, + os: MachineOs { + name: os_name, + version: os_version, + architecture: Some(architecture), + }, + mac_addresses, + serial_numbers: serials, + inventory, + metrics, + }) +} + +static HTTP_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .user_agent("sistema-de-chamados-agent/1.0") + .timeout(Duration::from_secs(20)) + .use_rustls_tls() + .build() + .expect("failed to build http client") +}); + +async fn post_heartbeat(base_url: &str, token: &str, status: Option) -> Result<(), AgentError> { + let system = collect_system(); + let metrics = collect_metrics(&system); + let hostname = hostname::get() + .map_err(|_| AgentError::Hostname)? + .to_string_lossy() + .into_owned(); + let os = MachineOs { + name: System::name() + .or_else(|| System::long_os_version()) + .unwrap_or_else(|| "desconhecido".to_string()), + version: System::os_version(), + architecture: Some(std::env::consts::ARCH.to_string()), + }; + + let payload = HeartbeatPayload { + machine_token: token.to_string(), + status, + hostname: Some(hostname), + os: Some(os), + metrics: Some(metrics), + metadata: None, + }; + + let url = format!("{}/api/machines/heartbeat", base_url); + HTTP_CLIENT.post(url).json(&payload).send().await?; + Ok(()) +} + +struct HeartbeatHandle { + token: String, + base_url: String, + status: Option, + stop_signal: Arc, + join_handle: JoinHandle<()>, +} + +impl HeartbeatHandle { + fn stop(self) { + self.stop_signal.notify_waiters(); + self.join_handle.abort(); + } +} + +#[derive(Default)] +pub struct AgentRuntime { + inner: Mutex>, +} + +fn sanitize_base_url(input: &str) -> Result { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err(AgentError::InvalidApiUrl); + } + Ok(trimmed.to_string()) +} + +impl AgentRuntime { + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + pub fn start_heartbeat( + &self, + base_url: String, + token: String, + status: Option, + interval_seconds: Option, + ) -> Result<(), AgentError> { + let sanitized_base = sanitize_base_url(&base_url)?; + let interval = interval_seconds.unwrap_or(300).max(60); + + { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + if handle.token == token && handle.base_url == sanitized_base { + // Reuse existing heartbeat; keep running. + *guard = Some(handle); + return Ok(()); + } + handle.stop(); + } + } + + let stop_signal = Arc::new(Notify::new()); + let stop_signal_clone = stop_signal.clone(); + let token_clone = token.clone(); + let base_clone = sanitized_base.clone(); + let status_clone = status.clone(); + + let join_handle = async_runtime::spawn(async move { + if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { + eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); + } + + let mut ticker = tokio::time::interval(Duration::from_secs(interval)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + // Wait interval + tokio::select! { + _ = stop_signal_clone.notified() => { + break; + } + _ = ticker.tick() => {} + } + + if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { + eprintln!("[agent] Falha ao enviar heartbeat: {error}"); + } + } + }); + + let handle = HeartbeatHandle { + token, + base_url: sanitized_base, + status, + stop_signal, + join_handle, + }; + + let mut guard = self.inner.lock(); + *guard = Some(handle); + + Ok(()) + } + + pub fn stop(&self) { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + handle.stop(); + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4a277ef..ec4ca58 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,14 +1,43 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod agent; + +use agent::{collect_profile, AgentRuntime, MachineProfile}; +use tauri_plugin_store::Builder as StorePluginBuilder; + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn collect_machine_profile() -> Result { + collect_profile().map_err(|error| error.to_string()) +} + +#[tauri::command] +fn start_machine_agent( + state: tauri::State, + base_url: String, + token: String, + status: Option, + interval_seconds: Option, +) -> Result<(), String> { + state + .start_heartbeat(base_url, token, status, interval_seconds) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +fn stop_machine_agent(state: tauri::State) -> Result<(), String> { + state.stop(); + Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(AgentRuntime::new()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .plugin(StorePluginBuilder::default().build()) + .invoke_handler(tauri::generate_handler![ + collect_machine_profile, + start_machine_agent, + stop_machine_agent + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 3ebe0ea..cfa83ad 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,21 +1,22 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "appsdesktop", + "productName": "Sistema de Chamados Desktop", "version": "0.1.0", - "identifier": "com.renan.appsdesktop", + "identifier": "br.com.esdrasrenan.sistemadechamados", "build": { - "beforeDevCommand": "", - "devUrl": "http://localhost:3000", - "beforeBuildCommand": "", - "frontendDist": "" + "beforeDevCommand": "pnpm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "pnpm run build", + "frontendDist": "../dist" }, "app": { "withGlobalTauri": true, "windows": [ { - "title": "appsdesktop", - "width": 800, - "height": 600 + "title": "Sistema de Chamados", + "width": 1100, + "height": 720, + "resizable": true } ], "security": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6968251..43f2065 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,6 +1,67 @@ +import { invoke } from "@tauri-apps/api/core" +import { Store } from "@tauri-apps/plugin-store" + +type MachineOs = { + name: string + version?: string | null + architecture?: string | null +} + +type MachineMetrics = { + collectedAt: string + cpuLogicalCores: number + cpuPhysicalCores?: number | null + cpuUsagePercent: number + loadAverageOne?: number | null + loadAverageFive?: number | null + loadAverageFifteen?: number | null + memoryTotalBytes: number + memoryUsedBytes: number + memoryUsedPercent: number + uptimeSeconds: number +} + +type MachineInventory = { + cpuBrand?: string | null + hostIdentifier?: string | null +} + +type MachineProfile = { + hostname: string + os: MachineOs + macAddresses: string[] + serialNumbers: string[] + inventory: MachineInventory + metrics: MachineMetrics +} + +type MachineRegisterResponse = { + machineId: string + tenantId?: string | null + companyId?: string | null + companySlug?: string | null + machineToken: string + machineEmail?: string | null + expiresAt?: number | null +} + +type AgentConfig = { + machineId: string + machineToken: string + tenantId?: string | null + companySlug?: string | null + machineEmail?: string | null + apiBaseUrl: string + appUrl: string + createdAt: number + lastSyncedAt?: number | null + expiresAt?: number | null +} + declare global { interface ImportMetaEnv { readonly VITE_APP_URL?: string + readonly VITE_API_BASE_URL?: string } interface ImportMeta { @@ -8,23 +69,370 @@ declare global { } } -const DEFAULT_URL = "http://localhost:3000"; +const STORE_FILENAME = "machine-agent.json" +const DEFAULT_APP_URL = "http://localhost:3000" -function resolveTargetUrl() { - const fromEnv = import.meta?.env?.VITE_APP_URL; - if (fromEnv && fromEnv.trim().length > 0) { - return fromEnv.trim(); +function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { + const trimmed = (value ?? fallback).trim() + if (!trimmed.startsWith("http")) { + return fallback } - return DEFAULT_URL; + return trimmed.replace(/\/+$/, "") } -function bootstrap() { - const targetUrl = resolveTargetUrl(); - if (!targetUrl.startsWith("http")) { - console.error("URL inválida para o app desktop:", targetUrl); - return; +const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL) +const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl) + +const alertElement = document.getElementById("alert-container") as HTMLDivElement | null +const contentElement = document.getElementById("content") as HTMLDivElement | null +const statusElement = document.getElementById("status-text") as HTMLParagraphElement | null + +function setAlert(message: string | null, variant: "info" | "error" | "success" = "info") { + if (!alertElement) return + if (!message) { + alertElement.textContent = "" + alertElement.className = "alert" + return } - window.location.replace(targetUrl); + alertElement.textContent = message + const extra = variant === "info" ? "" : ` ${variant}` + alertElement.className = `alert visible${extra}` } -document.addEventListener("DOMContentLoaded", bootstrap); +function setStatus(message: string) { + if (statusElement) { + statusElement.textContent = message + } +} + +const store = new Store(STORE_FILENAME) +let storeLoaded = false + +async function ensureStoreLoaded() { + if (!storeLoaded) { + try { + await store.load() + } catch (error) { + console.error("[agent] Falha ao carregar store", error) + } + storeLoaded = true + } +} + +async function loadConfig(): Promise { + try { + await ensureStoreLoaded() + const record = await store.get("config") + if (!record) return null + return record + } catch (error) { + console.error("[agent] Falha ao recuperar configuração", error) + return null + } +} + +async function saveConfig(config: AgentConfig) { + await ensureStoreLoaded() + await store.set("config", config) + await store.save() +} + +async function clearConfig() { + await ensureStoreLoaded() + await store.delete("config") + await store.save() +} + +async function collectMachineProfile(): Promise { + return await invoke("collect_machine_profile") +} + +async function startHeartbeat(config: AgentConfig) { + await invoke("start_machine_agent", { + baseUrl: config.apiBaseUrl, + token: config.machineToken, + status: "online", + intervalSeconds: 300, + }) +} + +async function stopHeartbeat() { + await invoke("stop_machine_agent") +} + +function formatBytes(bytes: number) { + if (!bytes || Number.isNaN(bytes)) return "—" + const units = ["B", "KB", "MB", "GB", "TB"] + let value = bytes + let index = 0 + while (value >= 1024 && index < units.length - 1) { + value /= 1024 + index += 1 + } + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}` +} + +function formatPercent(value: number) { + if (Number.isNaN(value)) return "—" + return `${value.toFixed(1)}%` +} + +function formatDate(timestamp?: number | null) { + if (!timestamp) return "—" + try { + return new Date(timestamp).toLocaleString() + } catch { + return "—" + } +} + +function renderMachineSummary(profile: MachineProfile) { + if (!contentElement) return + const macs = profile.macAddresses.length > 0 ? profile.macAddresses.join(", ") : "—" + const serials = profile.serialNumbers.length > 0 ? profile.serialNumbers.join(", ") : "—" + const metrics = profile.metrics + const lastCollection = metrics.collectedAt ? new Date(metrics.collectedAt).toLocaleString() : "—" + + return ` +
+
Hostname: ${profile.hostname}
+
Sistema: ${profile.os.name}${profile.os.version ? ` ${profile.os.version}` : ""} (${profile.os.architecture ?? "?"})
+
Endereços MAC: ${macs}
+
Identificadores: ${serials}
+
CPU: ${metrics.cpuPhysicalCores ?? metrics.cpuLogicalCores} núcleos · uso ${formatPercent(metrics.cpuUsagePercent)}
+
Memória: ${formatBytes(metrics.memoryUsedBytes)} / ${formatBytes(metrics.memoryTotalBytes)} (${formatPercent(metrics.memoryUsedPercent)})
+
Coletado em: ${lastCollection}
+
+ ` +} + +function renderRegistered(config: AgentConfig) { + if (!contentElement) return + const summaryHtml = ` +
+
ID da máquina: ${config.machineId}
+
Email vinculado: ${config.machineEmail ?? "—"}
+
Tenant: ${config.tenantId ?? "padrão"}
+
Empresa: ${config.companySlug ?? "não vinculada"}
+
Token expira em: ${formatDate(config.expiresAt)}
+
Última sincronização: ${formatDate(config.lastSyncedAt)}
+
Ambiente: ${config.appUrl}
+
+ ` + + contentElement.innerHTML = ` +

Esta máquina já está provisionada e com heartbeat ativo.

+ ${summaryHtml} +
+ + +
+ ` + + const openButton = document.getElementById("open-app") + const resetButton = document.getElementById("reset-agent") + + openButton?.addEventListener("click", () => redirectToApp(config)) + resetButton?.addEventListener("click", async () => { + await stopHeartbeat().catch(() => undefined) + await clearConfig() + setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success") + setTimeout(() => window.location.reload(), 600) + }) + + setStatus("Máquina provisionada. Redirecionando para a interface web…") + setTimeout(() => redirectToApp(config), 1500) +} + +function renderProvisionForm(profile: MachineProfile) { + if (!contentElement) return + + const summary = renderMachineSummary(profile) ?? "" + + contentElement.innerHTML = ` +
+ + + +
+ + +
+
+ ${summary} + ` + + const form = document.getElementById("provision-form") as HTMLFormElement | null + const refreshButton = document.getElementById("refresh-profile") + + form?.addEventListener("submit", (event) => { + event.preventDefault() + handleRegister(profile, form) + }) + + refreshButton?.addEventListener("click", async () => { + setStatus("Recolhendo informações atualizadas da máquina…") + try { + const updatedProfile = await collectMachineProfile() + renderProvisionForm(updatedProfile) + setStatus("Dados atualizados. Revise e confirme o provisionamento.") + } catch (error) { + console.error("[agent] Falha ao atualizar perfil da máquina", error) + setAlert("Não foi possível atualizar as informações da máquina.", "error") + } + }) +} + +async function handleRegister(profile: MachineProfile, form: HTMLFormElement) { + const submitButton = form.querySelector("button[type=submit]") as HTMLButtonElement | null + const formData = new FormData(form) + const provisioningSecret = (formData.get("provisioningSecret") as string | null)?.trim() + const tenantId = (formData.get("tenantId") as string | null)?.trim() + const companySlug = (formData.get("companySlug") as string | null)?.trim() + + if (!provisioningSecret) { + setAlert("Informe o código de provisionamento.", "error") + return + } + + try { + if (submitButton) { + submitButton.disabled = true + } + setAlert(null) + setStatus("Enviando dados de registro da máquina…") + + const payload = { + provisioningSecret, + tenantId: tenantId && tenantId.length > 0 ? tenantId : undefined, + companySlug: companySlug && companySlug.length > 0 ? companySlug : undefined, + hostname: profile.hostname, + os: profile.os, + macAddresses: profile.macAddresses, + serialNumbers: profile.serialNumbers, + metadata: { + inventory: profile.inventory, + metrics: profile.metrics, + }, + registeredBy: "desktop-agent", + } + + const response = await fetch(`${apiBaseUrl}/api/machines/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + let message = `Falha ao registrar máquina (${response.status})` + try { + const errorBody = await response.json() + if (errorBody?.error) { + message = errorBody.error + } + } catch { + // ignore + } + throw new Error(message) + } + + const data = (await response.json()) as MachineRegisterResponse + const config: AgentConfig = { + machineId: data.machineId, + machineToken: data.machineToken, + tenantId: data.tenantId ?? null, + companySlug: data.companySlug ?? null, + machineEmail: data.machineEmail ?? null, + apiBaseUrl, + appUrl, + createdAt: Date.now(), + lastSyncedAt: Date.now(), + expiresAt: data.expiresAt ?? null, + } + + await saveConfig(config) + await startHeartbeat(config) + + setAlert("Máquina registrada com sucesso! Abrindo a interface web…", "success") + setStatus("Autenticando dispositivo e abrindo o Sistema de Chamados.") + + setTimeout(() => redirectToApp(config), 800) + } catch (error) { + console.error("[agent] Erro no registro da máquina", error) + const message = error instanceof Error ? error.message : "Erro desconhecido ao registrar a máquina." + const normalized = message.toLowerCase() + if (normalized.includes("failed to fetch") || normalized.includes("load failed") || normalized.includes("network")) { + setAlert("Não foi possível se conectar ao servidor. Verifique a conexão e o endereço configurado.", "error") + } else if (normalized.includes("401") || normalized.includes("403") || normalized.includes("código de provisionamento inválido")) { + setAlert("Código de provisionamento inválido. Confirme o segredo configurado no servidor e tente novamente.", "error") + } else { + setAlert(message, "error") + } + setStatus("Revise os dados e tente novamente.") + if (submitButton) { + submitButton.disabled = false + } + } +} + +function redirectToApp(config: AgentConfig) { + const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}` + window.location.replace(url) +} + +async function ensureHeartbeat(config: AgentConfig): Promise { + const adjustedConfig = { + ...config, + apiBaseUrl, + appUrl, + lastSyncedAt: Date.now(), + } + + await saveConfig(adjustedConfig) + await startHeartbeat(adjustedConfig) + + return adjustedConfig +} + +async function bootstrap() { + setStatus("Iniciando agente desktop…") + setAlert(null) + + try { + const stored = await loadConfig() + if (stored?.machineToken) { + const updated = await ensureHeartbeat(stored) + renderRegistered(updated) + return + } + } catch (error) { + console.error("[agent] Falha ao iniciar com configuração existente", error) + setAlert("Não foi possível carregar a configuração armazenada. Você poderá reprovisionar abaixo.", "error") + } + + try { + setStatus("Coletando informações básicas da máquina…") + const profile = await collectMachineProfile() + renderProvisionForm(profile) + setStatus("Informe o código de provisionamento para registrar esta máquina.") + } catch (error) { + console.error("[agent] Falha ao coletar dados da máquina", error) + setAlert("Não foi possível coletar dados da máquina. Verifique permissões do sistema e tente novamente.", "error") + setStatus("Interação necessária para continuar.") + } +} + +document.addEventListener("DOMContentLoaded", () => { + void bootstrap() +}) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 7011746..80776e1 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1,116 +1,235 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} - -.logo.typescript:hover { - filter: drop-shadow(0 0 2em #2d79c7); -} :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + color-scheme: light dark; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -.container { + line-height: 1.5; + background-color: #f1f5f9; + color: #0f172a; margin: 0; - padding-top: 10vh; - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; +body { + margin: 0; } -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); +.app-root { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: radial-gradient(circle at top, rgba(59, 130, 246, 0.12), transparent 60%), + radial-gradient(circle at bottom, rgba(16, 185, 129, 0.12), transparent 55%); } -.row { - display: flex; - justify-content: center; +.card { + width: min(440px, 100%); + background-color: rgba(255, 255, 255, 0.85); + border-radius: 16px; + box-shadow: 0 16px 60px rgba(15, 23, 42, 0.16); + padding: 28px; + backdrop-filter: blur(12px); } -a { +.card header h1 { + margin: 0; + font-size: 1.75rem; +} + +.subtitle { + margin: 4px 0 0; + color: #475569; + font-size: 0.95rem; +} + +.alert { + margin-top: 16px; + font-size: 0.95rem; + color: #0f172a; + background-color: #e0f2fe; + border-radius: 12px; + padding: 12px 14px; + display: none; +} + +.alert.visible { + display: block; +} + +.alert.error { + background-color: #fee2e2; + color: #b91c1c; +} + +.alert.success { + background-color: #dcfce7; + color: #166534; +} + +form { + display: grid; + gap: 12px; + margin-top: 18px; +} + +label { + display: grid; + gap: 6px; font-weight: 500; - color: #646cff; - text-decoration: inherit; + color: #0f172a; } -a:hover { - color: #535bf2; -} - -h1 { - text-align: center; +label span.optional { + font-weight: 400; + color: #64748b; + font-size: 0.85rem; } input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +select { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.6); + font-size: 1rem; + background-color: rgba(241, 245, 249, 0.8); + color: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } -button { - cursor: pointer; -} - -button:hover { - border-color: #396cd8; -} -button:active { - border-color: #396cd8; - background-color: #e8e8e8; -} - -input, -button { +input:focus, +select:focus { outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); } -#greet-input { - margin-right: 5px; +button { + padding: 12px 16px; + border-radius: 12px; + border: none; + background-color: #2563eb; + color: #ffffff; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +button.secondary { + background: none; + color: #2563eb; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button:hover:not(:disabled) { + background-color: #1d4ed8; + transform: translateY(-1px); +} + +.machine-summary { + margin-top: 18px; + padding: 14px; + border-radius: 12px; + background-color: rgba(15, 23, 42, 0.05); + display: grid; + gap: 8px; + font-size: 0.95rem; +} + +.machine-summary strong { + font-weight: 600; +} + +.actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 20px; +} + +.actions button { + flex: 1; +} + +.status-text { + margin-top: 16px; + font-size: 0.95rem; + color: #334155; +} + +.spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 3px solid rgba(37, 99, 235, 0.14); + border-top-color: #2563eb; + animation: spin 0.8s linear infinite; + display: inline-block; + vertical-align: middle; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } @media (prefers-color-scheme: dark) { :root { - color: #f6f6f6; - background-color: #2f2f2f; + background-color: #0f172a; + color: #e2e8f0; } - a:hover { - color: #24c8db; + .card { + background-color: rgba(15, 23, 42, 0.75); + color: #e2e8f0; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.4); + } + + .subtitle { + color: #94a3b8; + } + + .alert { + background-color: rgba(37, 99, 235, 0.16); + color: #bfdbfe; + } + + .alert.error { + background-color: rgba(248, 113, 113, 0.2); + color: #fecaca; + } + + .alert.success { + background-color: rgba(34, 197, 94, 0.18); + color: #bbf7d0; } input, - button { - color: #ffffff; - background-color: #0f0f0f98; + select { + background-color: rgba(15, 23, 42, 0.5); + border-color: rgba(148, 163, 184, 0.35); } - button:active { - background-color: #0f0f0f69; + + input:focus, + select:focus { + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.32); + } + + button.secondary { + color: #93c5fd; + } + + .machine-summary { + background-color: rgba(148, 163, 184, 0.12); + } + + .status-text { + color: #cbd5f5; } } diff --git a/docs/plano-app-desktop-maquinas.md b/docs/plano-app-desktop-maquinas.md index 1013118..fce8da4 100644 --- a/docs/plano-app-desktop-maquinas.md +++ b/docs/plano-app-desktop-maquinas.md @@ -43,14 +43,20 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer. ## Notas de Implementação (Atual) - Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`. -- `src/main.ts` redireciona a WebView para `VITE_APP_URL` (padrão `http://localhost:3000`), reaproveitando a UI Next web. -- `index.html` exibe fallback simples enquanto o Next inicializa. -- Necessário criar `.env` em `apps/desktop` (ou usar variáveis de ambiente) com `VITE_APP_URL` correspondente ao ambiente. +- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a máquina e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`). +- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos. +- URLs configuráveis via `.env` do app desktop: + - `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`). + - `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`. +- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a máquina no Better Auth, devolve cookies e redireciona para a UI. +- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `pnpm run dev/build`, servir `dist/` e abrir janela 1100x720. - Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL). - Novos endpoints Next: - `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`). - `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL. - `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies. +- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`). +- Rota `GET /machines/handshake` realiza o login automático da máquina (seta cookies e redireciona). - Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`. - Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual. - Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias). @@ -71,4 +77,5 @@ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh --- > Histórico de atualizações: +> - 2025-02-20 — Fluxo completo do agente desktop, heartbeats e rota `/machines/handshake` documentados (assistente). > - 2025-02-14 — Documento criado com visão geral e plano macro (assistente). diff --git a/package.json b/package.json index 5959c60..2650f44 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "convex:dev": "convex dev", "test": "vitest", "auth:seed": "node scripts/seed-auth.mjs", - "queues:ensure": "node scripts/ensure-default-queues.mjs" + "queues:ensure": "node scripts/ensure-default-queues.mjs", + "desktop:dev": "pnpm --filter appsdesktop tauri dev", + "desktop:build": "pnpm --filter appsdesktop tauri build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc564f..2acfa54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: eslint-config-next: specifier: 15.5.4 version: 15.5.4(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-hooks: + specifier: ^5.0.0 + version: 5.2.0(eslint@9.37.0(jiti@2.6.1)) prisma: specifier: ^6.16.2 version: 6.16.3(typescript@5.9.3) @@ -211,6 +214,28 @@ importers: specifier: ^2.1.4 version: 2.1.9(@types/node@20.19.19)(lightningcss@1.30.1) + apps/desktop: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.8.0 + '@tauri-apps/plugin-opener': + specifier: ^2 + version: 2.5.0 + '@tauri-apps/plugin-store': + specifier: ^2 + version: 2.4.0 + devDependencies: + '@tauri-apps/cli': + specifier: ^2 + version: 2.8.4 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^6.0.3 + version: 6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1) + packages: '@alloc/quick-lru@5.2.0': @@ -1728,6 +1753,12 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-opener@2.5.0': + resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==} + + '@tauri-apps/plugin-store@2.4.0': + resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==} + '@tiptap/core@3.6.5': resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==} peerDependencies: @@ -3938,6 +3969,11 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4049,6 +4085,46 @@ packages: terser: optional: true + vite@6.3.6: + resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@2.1.9: resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -5432,6 +5508,14 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.8.4 '@tauri-apps/cli-win32-x64-msvc': 2.8.4 + '@tauri-apps/plugin-opener@2.5.0': + dependencies: + '@tauri-apps/api': 2.8.0 + + '@tauri-apps/plugin-store@2.4.0': + dependencies: + '@tauri-apps/api': 2.8.0 + '@tiptap/core@3.6.5(@tiptap/pm@3.6.5)': dependencies: '@tiptap/pm': 3.6.5 @@ -7982,6 +8066,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typescript@5.6.3: {} + typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -8115,6 +8201,20 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 + vite@6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1): + dependencies: + esbuild: 0.25.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.19 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + vitest@2.1.9(@types/node@20.19.19)(lightningcss@1.30.1): dependencies: '@vitest/expect': 2.1.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d275163..b4c4e18 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - . + - apps/desktop ignoredBuiltDependencies: - '@prisma/client' diff --git a/src/app/api/machines/heartbeat/route.ts b/src/app/api/machines/heartbeat/route.ts index cfd8b17..8ccc3fa 100644 --- a/src/app/api/machines/heartbeat/route.ts +++ b/src/app/api/machines/heartbeat/route.ts @@ -1,9 +1,9 @@ -import { NextResponse } from "next/server" import { z } from "zod" import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" import { env } from "@/lib/env" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" const heartbeatSchema = z.object({ machineToken: z.string().min(1), @@ -21,14 +21,20 @@ const heartbeatSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }) +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + export async function POST(request: Request) { if (request.method !== "POST") { - return NextResponse.json({ error: "Método não permitido" }, { status: 405 }) + return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) } const convexUrl = env.NEXT_PUBLIC_CONVEX_URL if (!convexUrl) { - return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) } let payload @@ -36,16 +42,21 @@ export async function POST(request: Request) { const raw = await request.json() payload = heartbeatSchema.parse(raw) } catch (error) { - return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 }) + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + request.headers.get("origin"), + CORS_METHODS + ) } const client = new ConvexHttpClient(convexUrl) try { const response = await client.mutation(api.machines.heartbeat, payload) - return NextResponse.json(response) + return jsonWithCors(response, 200, request.headers.get("origin"), CORS_METHODS) } catch (error) { console.error("[machines.heartbeat] Falha ao registrar heartbeat", error) - return NextResponse.json({ error: "Falha ao registrar heartbeat" }, { status: 500 }) + return jsonWithCors({ error: "Falha ao registrar heartbeat" }, 500, request.headers.get("origin"), CORS_METHODS) } } diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index 3f14609..18cfae9 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -1,4 +1,3 @@ -import { NextResponse } from "next/server" import { z } from "zod" import { ConvexHttpClient } from "convex/browser" @@ -7,6 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { ensureMachineAccount } from "@/server/machines-auth" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" const registerSchema = z .object({ @@ -29,14 +29,20 @@ const registerSchema = z { message: "Informe ao menos um MAC address ou número de série" } ) +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + export async function POST(request: Request) { if (request.method !== "POST") { - return NextResponse.json({ error: "Método não permitido" }, { status: 405 }) + return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) } const convexUrl = env.NEXT_PUBLIC_CONVEX_URL if (!convexUrl) { - return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) } let payload @@ -44,7 +50,12 @@ export async function POST(request: Request) { const raw = await request.json() payload = registerSchema.parse(raw) } catch (error) { - return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 }) + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + request.headers.get("origin"), + CORS_METHODS + ) } const client = new ConvexHttpClient(convexUrl) @@ -75,7 +86,7 @@ export async function POST(request: Request) { authEmail: account.authEmail, }) - return NextResponse.json( + return jsonWithCors( { machineId: registration.machineId, tenantId: registration.tenantId, @@ -85,10 +96,12 @@ export async function POST(request: Request) { machineEmail: account.authEmail, expiresAt: registration.expiresAt, }, - { status: 201 } + { status: 201 }, + request.headers.get("origin"), + CORS_METHODS ) } catch (error) { console.error("[machines.register] Falha no provisionamento", error) - return NextResponse.json({ error: "Falha ao provisionar máquina" }, { status: 500 }) + return jsonWithCors({ error: "Falha ao provisionar máquina" }, 500, request.headers.get("origin"), CORS_METHODS) } } diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index acaf61a..a11d3a7 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -1,27 +1,22 @@ import { NextResponse } from "next/server" import { z } from "zod" -import { ConvexHttpClient } from "convex/browser" - -import { api } from "@/convex/_generated/api" -import type { Id } from "@/convex/_generated/dataModel" -import { env } from "@/lib/env" -import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { ensureMachineAccount } from "@/server/machines-auth" -import { auth } from "@/lib/auth" +import { createMachineSession } from "@/server/machines-session" +import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors" const sessionSchema = z.object({ machineToken: z.string().min(1), rememberMe: z.boolean().optional(), }) +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + export async function POST(request: Request) { if (request.method !== "POST") { - return NextResponse.json({ error: "Método não permitido" }, { status: 405 }) - } - - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) } let payload @@ -29,68 +24,34 @@ export async function POST(request: Request) { const raw = await request.json() payload = sessionSchema.parse(raw) } catch (error) { - return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 }) + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + request.headers.get("origin"), + CORS_METHODS + ) } - const client = new ConvexHttpClient(convexUrl) - try { - const resolved = await client.mutation(api.machines.resolveToken, { machineToken: payload.machineToken }) - let machineEmail = resolved.machine.authEmail ?? null - - if (!machineEmail) { - const account = await ensureMachineAccount({ - machineId: resolved.machine._id, - tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID, - hostname: resolved.machine.hostname, - machineToken: payload.machineToken, - }) - - await client.mutation(api.machines.linkAuthAccount, { - machineId: resolved.machine._id as Id<"machines">, - authUserId: account.authUserId, - authEmail: account.authEmail, - }) - - machineEmail = account.authEmail - } - - const signIn = await auth.api.signInEmail({ - body: { - email: machineEmail, - password: payload.machineToken, - rememberMe: payload.rememberMe ?? true, - }, - returnHeaders: true, - }) - + const session = await createMachineSession(payload.machineToken, payload.rememberMe ?? true) const response = NextResponse.json( { ok: true, - machine: { - id: resolved.machine._id, - hostname: resolved.machine.hostname, - osName: resolved.machine.osName, - osVersion: resolved.machine.osVersion, - architecture: resolved.machine.architecture, - status: resolved.machine.status, - lastHeartbeatAt: resolved.machine.lastHeartbeatAt, - companyId: resolved.machine.companyId, - companySlug: resolved.machine.companySlug, - metadata: resolved.machine.metadata, - }, - session: signIn.response, + machine: session.machine, + session: session.response, }, { status: 200 } ) - signIn.headers.forEach((value, key) => { + session.headers.forEach((value, key) => { response.headers.set(key, value) }) + applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS) + return response } catch (error) { console.error("[machines.sessions] Falha ao criar sessão", error) - return NextResponse.json({ error: "Falha ao autenticar máquina" }, { status: 500 }) + return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, request.headers.get("origin"), CORS_METHODS) } } diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts new file mode 100644 index 0000000..869457e --- /dev/null +++ b/src/app/machines/handshake/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server" + +import { createMachineSession } from "@/server/machines-session" + +const ERROR_TEMPLATE = ` + + + + + + Falha na autenticação da máquina + + + +
+

Não foi possível autenticar esta máquina

+

O token informado é inválido, expirou ou não está mais associado a uma máquina ativa.

+

Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.

+ Voltar para o Sistema de Chamados +
+ + +` + +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get("token") + if (!token) { + return NextResponse.redirect(new URL("/", request.nextUrl.origin)) + } + + const redirectParam = request.nextUrl.searchParams.get("redirect") ?? "/" + const redirectUrl = new URL(redirectParam, request.nextUrl.origin) + + try { + const session = await createMachineSession(token, true) + const response = NextResponse.redirect(redirectUrl) + + session.headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + response.headers.append("set-cookie", value) + } else { + response.headers.set(key, value) + } + }) + + return response + } catch (error) { + console.error("[machines.handshake] Falha ao autenticar máquina", error) + return new NextResponse(ERROR_TEMPLATE, { + status: 500, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }) + } +} + diff --git a/src/server/cors.ts b/src/server/cors.ts new file mode 100644 index 0000000..2846563 --- /dev/null +++ b/src/server/cors.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server" + +const DEFAULT_ALLOWED_ORIGINS = [ + process.env.NEXT_PUBLIC_APP_URL ?? null, + "https://tickets.esdrasrenan.com.br", + "http://localhost:1420", + "http://localhost:3000", + "tauri://localhost", +].filter((value): value is string => Boolean(value)) + +export function resolveCorsOrigin(requestOrigin: string | null): string { + if (!requestOrigin) return "*" + const allowed = new Set(DEFAULT_ALLOWED_ORIGINS) + if (allowed.has(requestOrigin)) { + return requestOrigin + } + return "*" +} + +export function applyCorsHeaders(response: NextResponse, origin: string | null, methods = "POST, OPTIONS") { + const resolvedOrigin = resolveCorsOrigin(origin) + response.headers.set("Access-Control-Allow-Origin", resolvedOrigin) + response.headers.set("Access-Control-Allow-Methods", methods) + response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization") + response.headers.set("Access-Control-Max-Age", "86400") + response.headers.set("Vary", "Origin") + return response +} + +export function createCorsPreflight(origin: string | null, methods = "POST, OPTIONS") { + const response = new NextResponse(null, { status: 204 }) + return applyCorsHeaders(response, origin, methods) +} + +export function jsonWithCors(data: T, init: number | ResponseInit, origin: string | null, methods = "POST, OPTIONS") { + const response = NextResponse.json(data, typeof init === "number" ? { status: init } : init) + return applyCorsHeaders(response, origin, methods) +} diff --git a/src/server/machines-session.ts b/src/server/machines-session.ts new file mode 100644 index 0000000..a49d0cf --- /dev/null +++ b/src/server/machines-session.ts @@ -0,0 +1,81 @@ +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { env } from "@/lib/env" +import { ensureMachineAccount } from "@/server/machines-auth" +import { auth } from "@/lib/auth" + +export type MachineSessionContext = { + machine: { + id: Id<"machines"> + hostname: string + osName: string | null + osVersion: string | null + architecture: string | null + status: string | null + lastHeartbeatAt: number | null + companyId: Id<"companies"> | null + companySlug: string | null + metadata: Record | null + } + headers: Headers + response: unknown +} + +export async function createMachineSession(machineToken: string, rememberMe = true): Promise { + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + throw new Error("Convex não configurado") + } + + const client = new ConvexHttpClient(convexUrl) + + const resolved = await client.mutation(api.machines.resolveToken, { machineToken }) + let machineEmail = resolved.machine.authEmail ?? null + + if (!machineEmail) { + const account = await ensureMachineAccount({ + machineId: resolved.machine._id, + tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID, + hostname: resolved.machine.hostname, + machineToken, + }) + + await client.mutation(api.machines.linkAuthAccount, { + machineId: resolved.machine._id as Id<"machines">, + authUserId: account.authUserId, + authEmail: account.authEmail, + }) + + machineEmail = account.authEmail + } + + const signIn = await auth.api.signInEmail({ + body: { + email: machineEmail, + password: machineToken, + rememberMe, + }, + returnHeaders: true, + }) + + return { + machine: { + id: resolved.machine._id as Id<"machines">, + hostname: resolved.machine.hostname, + osName: resolved.machine.osName, + osVersion: resolved.machine.osVersion, + architecture: resolved.machine.architecture, + status: resolved.machine.status, + lastHeartbeatAt: resolved.machine.lastHeartbeatAt, + companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null, + companySlug: resolved.machine.companySlug ?? null, + metadata: (resolved.machine.metadata ?? null) as Record | null, + }, + headers: signIn.headers, + response: signIn.response, + } +} +