feat: habilitar provisionamento desktop e rotas CORS

This commit is contained in:
Esdras Renan 2025-10-08 23:07:49 -03:00
parent 7569986ffc
commit 152550a9a0
19 changed files with 1806 additions and 211 deletions

View file

@ -1,19 +1,23 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles.css" /> <link rel="stylesheet" href="/src/styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sistema de Chamados Desktop</title> <title>Sistema de Chamados — Agente Desktop</title>
<script type="module" src="/src/main.ts" defer></script> <script type="module" src="/src/main.ts" defer></script>
</head> </head>
<body> <body>
<main style="height: 100vh; display: grid; place-items: center;"> <main id="app-root" class="app-root">
<div style="text-align: center; font-family: system-ui, sans-serif;"> <section class="card">
<h1 style="margin-bottom: 0.5rem;">Abrindo Sistema de Chamados…</h1> <header>
<p style="color: #555;">Certifique-se de que o serviço web está disponível em <code>VITE_APP_URL</code>.</p> <h1>Sistema de Chamados</h1>
</div> <p class="subtitle">Agente desktop para provisionamento de máquinas</p>
</header>
<div id="alert-container" class="alert"></div>
<div id="content"></div>
<p id="status-text" class="status-text"></p>
</section>
</main> </main>
</body> </body>
</html> </html>

View file

@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2" "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",

View file

@ -60,11 +60,20 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
name = "appsdesktop" name = "appsdesktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"hostname",
"once_cell",
"parking_lot",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-store",
"thiserror 1.0.69",
"tokio",
] ]
[[package]] [[package]]
@ -486,8 +495,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@ -593,6 +604,25 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -821,6 +851,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.6" version = "3.0.6"
@ -1230,8 +1266,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1241,9 +1279,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasi 0.14.7+wasi-0.2.4", "wasi 0.14.7+wasi-0.2.4",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1436,6 +1476,17 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.29.1"
@ -1509,6 +1560,23 @@ dependencies = [
"want", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.17" version = "0.1.17"
@ -1947,6 +2015,12 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -2102,6 +2176,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -2822,6 +2905,61 @@ dependencies = [
"memchr", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.41" version = "1.0.41"
@ -2862,6 +3000,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@ -2882,6 +3030,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@ -2900,6 +3058,15 @@ dependencies = [
"getrandom 0.2.16", "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]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@ -2924,6 +3091,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -3007,16 +3194,21 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@ -3026,6 +3218,21 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "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]] [[package]]
@ -3034,6 +3241,12 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -3056,6 +3269,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -3489,6 +3737,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@ -3542,6 +3796,20 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@ -3589,7 +3857,7 @@ dependencies = [
"tao-macros", "tao-macros",
"unicode-segmentation", "unicode-segmentation",
"url", "url",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@ -3661,7 +3929,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"window-vibrancy", "window-vibrancy",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@ -3762,10 +4030,26 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.17", "thiserror 2.0.17",
"url", "url",
"windows", "windows 0.61.3",
"zbus", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.8.0" version = "2.8.0"
@ -3788,7 +4072,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@ -3814,7 +4098,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"wry", "wry",
] ]
@ -3971,6 +4255,21 @@ dependencies = [
"zerovec", "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]] [[package]]
name = "tokio" name = "tokio"
version = "1.47.1" version = "1.47.1"
@ -3985,9 +4284,31 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"slab", "slab",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.59.0", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.16" version = "0.7.16"
@ -4277,6 +4598,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"
@ -4501,6 +4828,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.1" version = "2.0.1"
@ -4545,6 +4882,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.0" version = "0.38.0"
@ -4553,10 +4899,10 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
] ]
[[package]] [[package]]
@ -4577,7 +4923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@ -4627,6 +4973,16 @@ dependencies = [
"windows-version", "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]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@ -4649,14 +5005,26 @@ dependencies = [
"windows-core 0.61.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.2",
@ -4668,8 +5036,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.2.1", "windows-link 0.2.1",
"windows-result 0.4.1", "windows-result 0.4.1",
"windows-strings 0.5.1", "windows-strings 0.5.1",
@ -4686,6 +5054,17 @@ dependencies = [
"windows-threading", "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]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@ -4697,6 +5076,17 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@ -4730,6 +5120,15 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@ -4775,6 +5174,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@ -5085,7 +5493,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@ -5237,6 +5645,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.2" version = "0.2.2"

View file

@ -18,8 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["wry"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-store = "2.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"

View file

@ -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<String>,
pub architecture: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MachineMetrics {
pub collected_at: DateTime<Utc>,
pub cpu_logical_cores: usize,
pub cpu_physical_cores: Option<usize>,
pub cpu_usage_percent: f32,
pub load_average_one: Option<f64>,
pub load_average_five: Option<f64>,
pub load_average_fifteen: Option<f64>,
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<String>,
pub host_identifier: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MachineProfile {
pub hostname: String,
pub os: MachineOs,
pub mac_addresses: Vec<String>,
pub serial_numbers: Vec<String>,
pub inventory: MachineInventory,
pub metrics: MachineMetrics,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct HeartbeatPayload {
machine_token: String,
status: Option<String>,
hostname: Option<String>,
os: Option<MachineOs>,
metrics: Option<MachineMetrics>,
metadata: Option<serde_json::Value>,
}
fn collect_mac_addresses() -> Vec<String> {
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::<Vec<_>>()
.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<MachineProfile, AgentError> {
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<String> = 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<reqwest::Client> = 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<String>) -> 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<String>,
stop_signal: Arc<Notify>,
join_handle: JoinHandle<()>,
}
impl HeartbeatHandle {
fn stop(self) {
self.stop_signal.notify_waiters();
self.join_handle.abort();
}
}
#[derive(Default)]
pub struct AgentRuntime {
inner: Mutex<Option<HeartbeatHandle>>,
}
fn sanitize_base_url(input: &str) -> Result<String, AgentError> {
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<String>,
interval_seconds: Option<u64>,
) -> 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();
}
}
}

View file

@ -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] #[tauri::command]
fn greet(name: &str) -> String { fn collect_machine_profile() -> Result<MachineProfile, String> {
format!("Hello, {}! You've been greeted from Rust!", name) collect_profile().map_err(|error| error.to_string())
}
#[tauri::command]
fn start_machine_agent(
state: tauri::State<AgentRuntime>,
base_url: String,
token: String,
status: Option<String>,
interval_seconds: Option<u64>,
) -> 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<AgentRuntime>) -> Result<(), String> {
state.stop();
Ok(())
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(AgentRuntime::new())
.plugin(tauri_plugin_opener::init()) .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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View file

@ -1,21 +1,22 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "appsdesktop", "productName": "Sistema de Chamados Desktop",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.renan.appsdesktop", "identifier": "br.com.esdrasrenan.sistemadechamados",
"build": { "build": {
"beforeDevCommand": "", "beforeDevCommand": "pnpm run dev",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "", "beforeBuildCommand": "pnpm run build",
"frontendDist": "" "frontendDist": "../dist"
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
"title": "appsdesktop", "title": "Sistema de Chamados",
"width": 800, "width": 1100,
"height": 600 "height": 720,
"resizable": true
} }
], ],
"security": { "security": {

View file

@ -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 { declare global {
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_APP_URL?: string readonly VITE_APP_URL?: string
readonly VITE_API_BASE_URL?: string
} }
interface ImportMeta { 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() { function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
const fromEnv = import.meta?.env?.VITE_APP_URL; const trimmed = (value ?? fallback).trim()
if (fromEnv && fromEnv.trim().length > 0) { if (!trimmed.startsWith("http")) {
return fromEnv.trim(); return fallback
} }
return DEFAULT_URL; return trimmed.replace(/\/+$/, "")
} }
function bootstrap() { const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL)
const targetUrl = resolveTargetUrl(); const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl)
if (!targetUrl.startsWith("http")) {
console.error("URL inválida para o app desktop:", targetUrl); const alertElement = document.getElementById("alert-container") as HTMLDivElement | null
return; 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<AgentConfig | null> {
try {
await ensureStoreLoaded()
const record = await store.get<AgentConfig>("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<MachineProfile> {
return await invoke<MachineProfile>("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 `
<div class="machine-summary">
<div><strong>Hostname:</strong> ${profile.hostname}</div>
<div><strong>Sistema:</strong> ${profile.os.name}${profile.os.version ? ` ${profile.os.version}` : ""} (${profile.os.architecture ?? "?"})</div>
<div><strong>Endereços MAC:</strong> ${macs}</div>
<div><strong>Identificadores:</strong> ${serials}</div>
<div><strong>CPU:</strong> ${metrics.cpuPhysicalCores ?? metrics.cpuLogicalCores} núcleos · uso ${formatPercent(metrics.cpuUsagePercent)}</div>
<div><strong>Memória:</strong> ${formatBytes(metrics.memoryUsedBytes)} / ${formatBytes(metrics.memoryTotalBytes)} (${formatPercent(metrics.memoryUsedPercent)})</div>
<div><strong>Coletado em:</strong> ${lastCollection}</div>
</div>
`
}
function renderRegistered(config: AgentConfig) {
if (!contentElement) return
const summaryHtml = `
<div class="machine-summary">
<div><strong>ID da máquina:</strong> ${config.machineId}</div>
<div><strong>Email vinculado:</strong> ${config.machineEmail ?? "—"}</div>
<div><strong>Tenant:</strong> ${config.tenantId ?? "padrão"}</div>
<div><strong>Empresa:</strong> ${config.companySlug ?? "não vinculada"}</div>
<div><strong>Token expira em:</strong> ${formatDate(config.expiresAt)}</div>
<div><strong>Última sincronização:</strong> ${formatDate(config.lastSyncedAt)}</div>
<div><strong>Ambiente:</strong> ${config.appUrl}</div>
</div>
`
contentElement.innerHTML = `
<p>Esta máquina está provisionada e com heartbeat ativo.</p>
${summaryHtml}
<div class="actions">
<button id="open-app">Abrir sistema</button>
<button class="secondary" id="reset-agent">Reprovisionar</button>
</div>
`
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 = `
<form id="provision-form">
<label>
Código de provisionamento
<input type="password" name="provisioningSecret" placeholder="Insira o código fornecido" required autocomplete="one-time-code" />
</label>
<label>
Tenant (opcional)
<span class="optional">Use apenas se houver múltiplos ambientes</span>
<input type="text" name="tenantId" placeholder="Ex.: tenant-atlas" />
</label>
<label>
Empresa (slug opcional)
<span class="optional">Informe para vincular à empresa correta</span>
<input type="text" name="companySlug" placeholder="Ex.: empresa-exemplo" />
</label>
<div class="actions">
<button type="submit">Registrar máquina</button>
<button type="button" class="secondary" id="refresh-profile">Atualizar dados</button>
</div>
</form>
${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<AgentConfig> {
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()
})

View file

@ -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 { :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; font-size: 16px;
line-height: 24px; line-height: 1.5;
font-weight: 400; background-color: #f1f5f9;
color: #0f172a;
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 {
margin: 0; margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
} }
.logo { body {
height: 6em; margin: 0;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
} }
.logo.tauri:hover { .app-root {
filter: drop-shadow(0 0 2em #24c8db); 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 { .card {
display: flex; width: min(440px, 100%);
justify-content: center; 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; font-weight: 500;
color: #646cff; color: #0f172a;
text-decoration: inherit;
} }
a:hover { label span.optional {
color: #535bf2; font-weight: 400;
} color: #64748b;
font-size: 0.85rem;
h1 {
text-align: center;
} }
input, input,
button { select {
border-radius: 8px; padding: 10px 12px;
border: 1px solid transparent; border-radius: 10px;
padding: 0.6em 1.2em; border: 1px solid rgba(148, 163, 184, 0.6);
font-size: 1em; font-size: 1rem;
font-weight: 500; background-color: rgba(241, 245, 249, 0.8);
font-family: inherit; color: inherit;
color: #0f0f0f; transition: border-color 0.2s ease, box-shadow 0.2s ease;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
} }
button { input:focus,
cursor: pointer; select:focus {
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none; outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
} }
#greet-input { button {
margin-right: 5px; 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) { @media (prefers-color-scheme: dark) {
:root { :root {
color: #f6f6f6; background-color: #0f172a;
background-color: #2f2f2f; color: #e2e8f0;
} }
a:hover { .card {
color: #24c8db; 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, input,
button { select {
color: #ffffff; background-color: rgba(15, 23, 42, 0.5);
background-color: #0f0f0f98; 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;
} }
} }

View file

@ -43,14 +43,20 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
## Notas de Implementação (Atual) ## Notas de Implementação (Atual)
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`. - 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. - 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`).
- `index.html` exibe fallback simples enquanto o Next inicializa. - 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.
- Necessário criar `.env` em `apps/desktop` (ou usar variáveis de ambiente) com `VITE_APP_URL` correspondente ao ambiente. - 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). - Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
- Novos endpoints Next: - Novos endpoints Next:
- `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`). - `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/heartbeat` — atualiza estado, métricas e renova TTL.
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies. - `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`. - 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. - 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). - 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: > 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). > - 2025-02-14 — Documento criado com visão geral e plano macro (assistente).

View file

@ -11,7 +11,9 @@
"convex:dev": "convex dev", "convex:dev": "convex dev",
"test": "vitest", "test": "vitest",
"auth:seed": "node scripts/seed-auth.mjs", "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": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

100
pnpm-lock.yaml generated
View file

@ -195,6 +195,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: 15.5.4 specifier: 15.5.4
version: 15.5.4(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) 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: prisma:
specifier: ^6.16.2 specifier: ^6.16.2
version: 6.16.3(typescript@5.9.3) version: 6.16.3(typescript@5.9.3)
@ -211,6 +214,28 @@ importers:
specifier: ^2.1.4 specifier: ^2.1.4
version: 2.1.9(@types/node@20.19.19)(lightningcss@1.30.1) 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: packages:
'@alloc/quick-lru@5.2.0': '@alloc/quick-lru@5.2.0':
@ -1728,6 +1753,12 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true 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': '@tiptap/core@3.6.5':
resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==} resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==}
peerDependencies: peerDependencies:
@ -3938,6 +3969,11 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typescript@5.6.3:
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'}
hasBin: true
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@ -4049,6 +4085,46 @@ packages:
terser: terser:
optional: true 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: vitest@2.1.9:
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
engines: {node: ^18.0.0 || >=20.0.0} 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-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-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)': '@tiptap/core@3.6.5(@tiptap/pm@3.6.5)':
dependencies: dependencies:
'@tiptap/pm': 3.6.5 '@tiptap/pm': 3.6.5
@ -7982,6 +8066,8 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typescript@5.6.3: {}
typescript@5.9.3: {} typescript@5.9.3: {}
uc.micro@2.1.0: {} uc.micro@2.1.0: {}
@ -8115,6 +8201,20 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
lightningcss: 1.30.1 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): vitest@2.1.9(@types/node@20.19.19)(lightningcss@1.30.1):
dependencies: dependencies:
'@vitest/expect': 2.1.9 '@vitest/expect': 2.1.9

View file

@ -1,5 +1,6 @@
packages: packages:
- . - .
- apps/desktop
ignoredBuiltDependencies: ignoredBuiltDependencies:
- '@prisma/client' - '@prisma/client'

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import { ConvexHttpClient } from "convex/browser" import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env" import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const heartbeatSchema = z.object({ const heartbeatSchema = z.object({
machineToken: z.string().min(1), machineToken: z.string().min(1),
@ -21,14 +21,20 @@ const heartbeatSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(), 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) { export async function POST(request: Request) {
if (request.method !== "POST") { 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 const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) { 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 let payload
@ -36,16 +42,21 @@ export async function POST(request: Request) {
const raw = await request.json() const raw = await request.json()
payload = heartbeatSchema.parse(raw) payload = heartbeatSchema.parse(raw)
} catch (error) { } 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) const client = new ConvexHttpClient(convexUrl)
try { try {
const response = await client.mutation(api.machines.heartbeat, payload) 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) { } catch (error) {
console.error("[machines.heartbeat] Falha ao registrar heartbeat", 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)
} }
} }

View file

@ -1,4 +1,3 @@
import { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import { ConvexHttpClient } from "convex/browser" import { ConvexHttpClient } from "convex/browser"
@ -7,6 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env" import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureMachineAccount } from "@/server/machines-auth" import { ensureMachineAccount } from "@/server/machines-auth"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const registerSchema = z const registerSchema = z
.object({ .object({
@ -29,14 +29,20 @@ const registerSchema = z
{ message: "Informe ao menos um MAC address ou número de série" } { 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) { export async function POST(request: Request) {
if (request.method !== "POST") { 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 const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) { 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 let payload
@ -44,7 +50,12 @@ export async function POST(request: Request) {
const raw = await request.json() const raw = await request.json()
payload = registerSchema.parse(raw) payload = registerSchema.parse(raw)
} catch (error) { } 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) const client = new ConvexHttpClient(convexUrl)
@ -75,7 +86,7 @@ export async function POST(request: Request) {
authEmail: account.authEmail, authEmail: account.authEmail,
}) })
return NextResponse.json( return jsonWithCors(
{ {
machineId: registration.machineId, machineId: registration.machineId,
tenantId: registration.tenantId, tenantId: registration.tenantId,
@ -85,10 +96,12 @@ export async function POST(request: Request) {
machineEmail: account.authEmail, machineEmail: account.authEmail,
expiresAt: registration.expiresAt, expiresAt: registration.expiresAt,
}, },
{ status: 201 } { status: 201 },
request.headers.get("origin"),
CORS_METHODS
) )
} catch (error) { } catch (error) {
console.error("[machines.register] Falha no provisionamento", 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)
} }
} }

View file

@ -1,27 +1,22 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import { ConvexHttpClient } from "convex/browser" import { createMachineSession } from "@/server/machines-session"
import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors"
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"
const sessionSchema = z.object({ const sessionSchema = z.object({
machineToken: z.string().min(1), machineToken: z.string().min(1),
rememberMe: z.boolean().optional(), 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) { export async function POST(request: Request) {
if (request.method !== "POST") { 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 })
} }
let payload let payload
@ -29,68 +24,34 @@ export async function POST(request: Request) {
const raw = await request.json() const raw = await request.json()
payload = sessionSchema.parse(raw) payload = sessionSchema.parse(raw)
} catch (error) { } 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 { try {
const resolved = await client.mutation(api.machines.resolveToken, { machineToken: payload.machineToken }) const session = await createMachineSession(payload.machineToken, payload.rememberMe ?? true)
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 response = NextResponse.json( const response = NextResponse.json(
{ {
ok: true, ok: true,
machine: { machine: session.machine,
id: resolved.machine._id, session: session.response,
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,
}, },
{ status: 200 } { status: 200 }
) )
signIn.headers.forEach((value, key) => { session.headers.forEach((value, key) => {
response.headers.set(key, value) response.headers.set(key, value)
}) })
applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS)
return response return response
} catch (error) { } catch (error) {
console.error("[machines.sessions] Falha ao criar sessão", 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)
} }
} }

View file

@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server"
import { createMachineSession } from "@/server/machines-session"
const ERROR_TEMPLATE = `
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Falha na autenticação da máquina</title>
<style>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #0f172a; color: #e2e8f0; margin: 0; display: grid; place-items: center; height: 100vh; }
main { max-width: 480px; padding: 32px; border-radius: 16px; background-color: rgba(15, 23, 42, 0.65); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.45); text-align: center; backdrop-filter: blur(10px); }
h1 { margin: 0 0 12px; font-size: 1.6rem; }
p { margin: 6px 0; line-height: 1.5; }
a { display: inline-block; margin-top: 18px; padding: 10px 16px; border-radius: 10px; background-color: #2563eb; color: #fff; text-decoration: none; font-weight: 600; }
a:hover { background-color: #1d4ed8; }
</style>
</head>
<body>
<main>
<h1>Não foi possível autenticar esta máquina</h1>
<p>O token informado é inválido, expirou ou não está mais associado a uma máquina ativa.</p>
<p>Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.</p>
<a href="/">Voltar para o Sistema de Chamados</a>
</main>
</body>
</html>
`
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",
},
})
}
}

38
src/server/cors.ts Normal file
View file

@ -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<T>(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)
}

View file

@ -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<string, unknown> | null
}
headers: Headers
response: unknown
}
export async function createMachineSession(machineToken: string, rememberMe = true): Promise<MachineSessionContext> {
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<string, unknown> | null,
},
headers: signIn.headers,
response: signIn.response,
}
}