diff --git a/Cargo.lock b/Cargo.lock index 11e94cf..c28320d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arrayref" version = "0.3.9" @@ -127,7 +133,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand", + "rand 0.9.5", "raw-window-handle", "serde", "serde_repr", @@ -320,6 +326,20 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_generate_cdp" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359220d0b9360b79d17d648d0a3ba1e792ec36bdbc227c8fd0351df3a0415704" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -377,6 +397,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -443,6 +472,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -565,6 +600,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -660,6 +706,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -754,6 +809,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -806,6 +870,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -830,14 +904,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -854,17 +952,34 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -877,6 +992,37 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "diesel" version = "2.3.11" @@ -923,15 +1069,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -1028,6 +1184,7 @@ dependencies = [ "diesel", "diesel_migrations", "git2", + "headless_chrome", "iced", "image", "memmap2", @@ -1051,7 +1208,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck 0.5.0", "proc-macro2", @@ -1472,6 +1629,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1523,6 +1690,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1850,6 +2018,29 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "headless_chrome" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5754ca220578ad74a5d5174c8ca2ff956fd3b94025f870844de1e910b47dfee5" +dependencies = [ + "anyhow", + "auto_generate_cdp", + "base64", + "derive_builder", + "log", + "rand 0.10.2", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tungstenite", + "url", + "which", + "winreg", +] + [[package]] name = "heck" version = "0.4.1" @@ -3663,7 +3854,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3673,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3685,6 +3887,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.5" @@ -4122,6 +4330,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -4129,8 +4348,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4308,6 +4527,17 @@ dependencies = [ "serde", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4828,6 +5058,22 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", +] + [[package]] name = "turbovault-core" version = "1.6.0" @@ -4976,10 +5222,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "socks", "ureq-proto", "utf8-zero", "webpki-roots", @@ -5500,6 +5748,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5886,6 +6143,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 7121b50..0b0e44a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ cc = "1.3.0" diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel_migrations = "2.3.2" git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] } +headless_chrome = "1.0.22" iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] } image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } memmap2 = "0.9.11" diff --git a/build.rs b/build.rs index 242ead2..b546f52 100644 --- a/build.rs +++ b/build.rs @@ -18,12 +18,6 @@ fn main() { .file("native/media/ds4_media.m") .flag("-fobjc-arc") .compile("ds4_media"); - println!("cargo:rerun-if-changed=native/web"); - cc::Build::new() - .include("native/web") - .file("native/web/ds4_web.c") - .opt_level(2) - .compile("ds4_web"); println!("cargo:rustc-link-lib=framework=Foundation"); println!("cargo:rustc-link-lib=framework=AppKit"); println!("cargo:rustc-link-lib=framework=AVFoundation"); diff --git a/native/web/ds4_web.c b/native/web/ds4_web.c deleted file mode 100644 index 209ef00..0000000 --- a/native/web/ds4_web.c +++ /dev/null @@ -1,1382 +0,0 @@ -#include "ds4_web.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifndef PATH_MAX -#define PATH_MAX 4096 -#endif - -#define DS4_WEB_DEFAULT_PORT 9333 -#define DS4_WEB_CONNECT_TIMEOUT_MS 3000 -#define DS4_WEB_CDP_TIMEOUT_MS 20000 -#define DS4_WEB_MAX_RESULT_BYTES (1024*1024) - -typedef struct { - char *ptr; - size_t len; - size_t cap; -} web_buf; - -struct ds4_web { - char profile_dir[PATH_MAX]; - int port; - pid_t chrome_pid; - bool browser_allowed; - ds4_web_confirm_fn confirm; - void *confirm_privdata; - ds4_web_log_fn log; - void *log_privdata; - ds4_web_cancel_fn cancel; - void *cancel_privdata; - int next_cdp_id; -}; - -typedef struct { - int fd; - int next_id; - ds4_web *web; -} cdp_ws; - -typedef struct { - char *id; - char *ws_url; -} web_tab; - -static void *web_xmalloc(size_t n) { - void *p = malloc(n ? n : 1); - if (!p) { - perror("ds4_web: malloc"); - exit(1); - } - return p; -} - -static char *web_xstrdup(const char *s) { - if (!s) s = ""; - size_t n = strlen(s); - char *p = web_xmalloc(n + 1); - memcpy(p, s, n + 1); - return p; -} - -static void web_buf_append(web_buf *b, const char *s, size_t n) { - if (!n) return; - if (b->len + n + 1 > b->cap) { - size_t cap = b->cap ? b->cap * 2 : 4096; - while (cap < b->len + n + 1) cap *= 2; - char *p = realloc(b->ptr, cap); - if (!p) { - perror("ds4_web: realloc"); - exit(1); - } - b->ptr = p; - b->cap = cap; - } - memcpy(b->ptr + b->len, s, n); - b->len += n; - b->ptr[b->len] = '\0'; -} - -static void web_buf_puts(web_buf *b, const char *s) { - web_buf_append(b, s, strlen(s)); -} - -static char *web_buf_take(web_buf *b) { - if (!b->ptr) return web_xstrdup(""); - char *p = b->ptr; - b->ptr = NULL; - b->len = b->cap = 0; - return p; -} - -static void web_set_err(char *err, size_t err_len, const char *fmt, ...) { - if (!err || err_len == 0) return; - va_list ap; - va_start(ap, fmt); - vsnprintf(err, err_len, fmt, ap); - va_end(ap); -} - -static void web_log(ds4_web *web, const char *msg) { - if (web && web->log) web->log(web->log_privdata, msg); -} - -static double web_now_sec(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0; -} - -static bool web_cancelled(ds4_web *web) { - return web && web->cancel && web->cancel(web->cancel_privdata); -} - -static bool web_set_cancel_err(ds4_web *web, char *err, size_t err_len) { - if (!web_cancelled(web)) return false; - web_set_err(err, err_len, "interrupted"); - return true; -} - -static bool web_sleep_ms(ds4_web *web, int ms) { - int left = ms; - while (left > 0) { - if (web_cancelled(web)) return false; - int step = left < 50 ? left : 50; - usleep((useconds_t)step * 1000u); - left -= step; - } - return !web_cancelled(web); -} - -static bool web_err_is_interrupted(const char *err) { - return err && !strcmp(err, "interrupted"); -} - -static bool web_mkdir_p(const char *path) { - if (!path || !path[0]) return false; - char tmp[PATH_MAX]; - snprintf(tmp, sizeof(tmp), "%s", path); - for (char *p = tmp + 1; *p; p++) { - if (*p != '/') continue; - *p = '\0'; - if (mkdir(tmp, 0700) != 0 && errno != EEXIST) return false; - *p = '/'; - } - return mkdir(tmp, 0700) == 0 || errno == EEXIST; -} - -static int web_tcp_connect(const char *host, int port, int timeout_ms, - char *err, size_t err_len) { - char service[32]; - snprintf(service, sizeof(service), "%d", port); - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_socktype = SOCK_STREAM; - hints.ai_family = AF_UNSPEC; - struct addrinfo *res = NULL; - int gai = getaddrinfo(host, service, &hints, &res); - if (gai != 0) { - web_set_err(err, err_len, "getaddrinfo %s: %s", host, gai_strerror(gai)); - return -1; - } - - int fd = -1; - for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { - fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); - if (fd < 0) continue; - int flags = fcntl(fd, F_GETFL, 0); - if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); - int rc = connect(fd, ai->ai_addr, ai->ai_addrlen); - if (rc == 0) { - if (flags >= 0) fcntl(fd, F_SETFL, flags); - break; - } - if (errno == EINPROGRESS) { - struct pollfd pfd = {.fd = fd, .events = POLLOUT}; - rc = poll(&pfd, 1, timeout_ms); - if (rc > 0) { - int soerr = 0; - socklen_t slen = sizeof(soerr); - getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen); - if (soerr == 0) { - if (flags >= 0) fcntl(fd, F_SETFL, flags); - break; - } - errno = soerr; - } - } - close(fd); - fd = -1; - } - freeaddrinfo(res); - if (fd < 0) web_set_err(err, err_len, "connect %s:%d failed: %s", - host, port, strerror(errno)); - return fd; -} - -static int web_write_all(int fd, const void *buf, size_t len) { - const char *p = buf; - while (len) { -#ifdef MSG_NOSIGNAL - ssize_t n = send(fd, p, len, MSG_NOSIGNAL); -#else - ssize_t n = write(fd, p, len); -#endif - if (n < 0 && errno == EINTR) continue; - if (n <= 0) return -1; - p += n; - len -= (size_t)n; - } - return 0; -} - -static ssize_t web_read_some(int fd, char *buf, size_t len, int timeout_ms) { - struct pollfd pfd = {.fd = fd, .events = POLLIN}; - int rc = poll(&pfd, 1, timeout_ms); - if (rc <= 0) return rc == 0 ? 0 : -1; - for (;;) { - ssize_t n = read(fd, buf, len); - if (n < 0 && errno == EINTR) continue; - return n; - } -} - -static char *web_http_request(const char *method, int port, const char *path, - char *err, size_t err_len) { - int fd = web_tcp_connect("127.0.0.1", port, DS4_WEB_CONNECT_TIMEOUT_MS, - err, err_len); - if (fd < 0) return NULL; - web_buf req = {0}; - char line[512]; - snprintf(line, sizeof(line), - "%s %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: close\r\n\r\n", - method, path, port); - web_buf_puts(&req, line); - if (web_write_all(fd, req.ptr, req.len) != 0) { - web_set_err(err, err_len, "write HTTP request failed: %s", strerror(errno)); - close(fd); - free(req.ptr); - return NULL; - } - free(req.ptr); - - web_buf resp = {0}; - char tmp[4096]; - for (;;) { - ssize_t n = web_read_some(fd, tmp, sizeof(tmp), DS4_WEB_CONNECT_TIMEOUT_MS); - if (n < 0) { - web_set_err(err, err_len, "read HTTP response failed: %s", strerror(errno)); - close(fd); - free(resp.ptr); - return NULL; - } - if (n == 0) break; - web_buf_append(&resp, tmp, (size_t)n); - } - close(fd); - if (!resp.ptr) { - web_set_err(err, err_len, "empty HTTP response"); - return NULL; - } - char *body = strstr(resp.ptr, "\r\n\r\n"); - if (!body) { - web_set_err(err, err_len, "malformed HTTP response"); - free(resp.ptr); - return NULL; - } - body += 4; - char *out = web_xstrdup(body); - free(resp.ptr); - return out; -} - -static bool web_cdp_alive(ds4_web *web) { - char err[160] = {0}; - char *body = web_http_request("GET", web->port, "/json/version", err, sizeof(err)); - if (!body) return false; - bool ok = strstr(body, "webSocketDebuggerUrl") != NULL; - free(body); - return ok; -} - -static char *web_json_get_string(const char *json, const char *key); - -static char *web_url_encode(const char *s) { - static const char hex[] = "0123456789ABCDEF"; - web_buf b = {0}; - for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) { - unsigned char c = *p; - if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { - web_buf_append(&b, (const char *)&c, 1); - } else { - char e[3] = {'%', hex[c >> 4], hex[c & 15]}; - web_buf_append(&b, e, 3); - } - } - return web_buf_take(&b); -} - -static void web_random_bytes(unsigned char *buf, size_t len) { - int fd = open("/dev/urandom", O_RDONLY); - if (fd >= 0) { - size_t off = 0; - while (off < len) { - ssize_t n = read(fd, buf + off, len - off); - if (n < 0 && errno == EINTR) continue; - if (n <= 0) break; - off += (size_t)n; - } - close(fd); - if (off == len) return; - } - uint64_t x = (uint64_t)time(NULL) ^ ((uint64_t)getpid() << 32); - for (size_t i = 0; i < len; i++) { - x = x * 6364136223846793005ULL + 1; - buf[i] = (unsigned char)(x >> 32); - } -} - -static char *web_base64(const unsigned char *data, size_t len) { - static const char tab[] = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - size_t outlen = ((len + 2) / 3) * 4; - char *out = web_xmalloc(outlen + 1); - size_t j = 0; - for (size_t i = 0; i < len; i += 3) { - uint32_t v = (uint32_t)data[i] << 16; - if (i + 1 < len) v |= (uint32_t)data[i + 1] << 8; - if (i + 2 < len) v |= data[i + 2]; - out[j++] = tab[(v >> 18) & 63]; - out[j++] = tab[(v >> 12) & 63]; - out[j++] = (i + 1 < len) ? tab[(v >> 6) & 63] : '='; - out[j++] = (i + 2 < len) ? tab[v & 63] : '='; - } - out[j] = '\0'; - return out; -} - -static char *web_json_quote(const char *s) { - web_buf b = {0}; - web_buf_puts(&b, "\""); - for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) { - unsigned char c = *p; - switch (c) { - case '\\': web_buf_puts(&b, "\\\\"); break; - case '"': web_buf_puts(&b, "\\\""); break; - case '\n': web_buf_puts(&b, "\\n"); break; - case '\r': web_buf_puts(&b, "\\r"); break; - case '\t': web_buf_puts(&b, "\\t"); break; - default: - if (c < 0x20) { - char tmp[8]; - snprintf(tmp, sizeof(tmp), "\\u%04x", c); - web_buf_puts(&b, tmp); - } else { - web_buf_append(&b, (const char *)&c, 1); - } - break; - } - } - web_buf_puts(&b, "\""); - return web_buf_take(&b); -} - -static int web_ws_connect(const char *ws_url, cdp_ws *ws, - char *err, size_t err_len) { - const char *p = ws_url; - if (strncmp(p, "ws://", 5) != 0) { - web_set_err(err, err_len, "unsupported websocket URL: %s", ws_url); - return -1; - } - p += 5; - const char *slash = strchr(p, '/'); - if (!slash) { - web_set_err(err, err_len, "malformed websocket URL"); - return -1; - } - char hostport[256]; - size_t hp_len = (size_t)(slash - p); - if (hp_len >= sizeof(hostport)) hp_len = sizeof(hostport) - 1; - memcpy(hostport, p, hp_len); - hostport[hp_len] = '\0'; - char *colon = strrchr(hostport, ':'); - int port = 80; - if (colon) { - *colon = '\0'; - port = atoi(colon + 1); - } - const char *host = hostport; - int fd = web_tcp_connect(host, port, DS4_WEB_CONNECT_TIMEOUT_MS, err, err_len); - if (fd < 0) return -1; - - unsigned char rnd[16]; - web_random_bytes(rnd, sizeof(rnd)); - char *key = web_base64(rnd, sizeof(rnd)); - web_buf req = {0}; - char line[512]; - snprintf(line, sizeof(line), - "GET %s HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Key: %s\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n", - slash, host, port, key); - web_buf_puts(&req, line); - free(key); - if (web_write_all(fd, req.ptr, req.len) != 0) { - web_set_err(err, err_len, "websocket handshake write failed"); - close(fd); - free(req.ptr); - return -1; - } - free(req.ptr); - - web_buf resp = {0}; - char tmp[1024]; - double deadline = web_now_sec() + (double)DS4_WEB_CONNECT_TIMEOUT_MS / 1000.0; - while (!strstr(resp.ptr ? resp.ptr : "", "\r\n\r\n")) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) { - close(fd); - free(resp.ptr); - return -1; - } - double now = web_now_sec(); - if (now >= deadline) { - web_set_err(err, err_len, "websocket handshake read failed"); - close(fd); - free(resp.ptr); - return -1; - } - int slice = 100; - int remaining = (int)((deadline - now) * 1000.0); - if (remaining < slice) slice = remaining > 0 ? remaining : 1; - ssize_t n = web_read_some(fd, tmp, sizeof(tmp), slice); - if (n < 0) { - web_set_err(err, err_len, "websocket handshake read failed"); - close(fd); - free(resp.ptr); - return -1; - } - if (n == 0) continue; - web_buf_append(&resp, tmp, (size_t)n); - if (resp.len > 8192) break; - } - bool ok = resp.ptr && strstr(resp.ptr, " 101 ") != NULL; - free(resp.ptr); - if (!ok) { - web_set_err(err, err_len, "websocket handshake rejected"); - close(fd); - return -1; - } - ws->fd = fd; - ws->next_id = 1; - return 0; -} - -static void web_ws_close(cdp_ws *ws) { - if (ws && ws->fd >= 0) { - close(ws->fd); - ws->fd = -1; - } -} - -static int web_read_exact(cdp_ws *ws, unsigned char *buf, size_t len, - int timeout_ms, char *err, size_t err_len) { - size_t off = 0; - double deadline = web_now_sec() + (double)timeout_ms / 1000.0; - while (off < len) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return -1; - double now = web_now_sec(); - if (now >= deadline) { - web_set_err(err, err_len, "websocket read timeout"); - return -1; - } - int slice = 100; - int remaining = (int)((deadline - now) * 1000.0); - if (remaining < slice) slice = remaining > 0 ? remaining : 1; - ssize_t n = web_read_some(ws->fd, (char *)buf + off, len - off, slice); - if (n < 0) { - web_set_err(err, err_len, "websocket frame read failed"); - return -1; - } - if (n == 0) continue; - off += (size_t)n; - } - return 0; -} - -static int web_ws_send_text(cdp_ws *ws, const char *text, - char *err, size_t err_len) { - size_t len = strlen(text); - web_buf frame = {0}; - unsigned char hdr[14]; - size_t h = 0; - hdr[h++] = 0x81; - if (len < 126) { - hdr[h++] = 0x80 | (unsigned char)len; - } else if (len <= 0xffff) { - hdr[h++] = 0x80 | 126; - hdr[h++] = (unsigned char)(len >> 8); - hdr[h++] = (unsigned char)len; - } else { - hdr[h++] = 0x80 | 127; - for (int i = 7; i >= 0; i--) hdr[h++] = (unsigned char)((uint64_t)len >> (i * 8)); - } - unsigned char mask[4]; - web_random_bytes(mask, sizeof(mask)); - for (int i = 0; i < 4; i++) hdr[h++] = mask[i]; - web_buf_append(&frame, (const char *)hdr, h); - for (size_t i = 0; i < len; i++) { - char c = text[i] ^ mask[i & 3]; - web_buf_append(&frame, &c, 1); - } - int rc = web_write_all(ws->fd, frame.ptr, frame.len); - free(frame.ptr); - if (rc != 0) { - web_set_err(err, err_len, "websocket write failed: %s", strerror(errno)); - return -1; - } - return 0; -} - -static int web_ws_send_pong(cdp_ws *ws, const unsigned char *payload, size_t len) { - if (len > 125) len = 125; - unsigned char hdr[2 + 4 + 125]; - hdr[0] = 0x8a; - hdr[1] = 0x80 | (unsigned char)len; - unsigned char mask[4]; - web_random_bytes(mask, sizeof(mask)); - memcpy(hdr + 2, mask, 4); - for (size_t i = 0; i < len; i++) hdr[6 + i] = payload[i] ^ mask[i & 3]; - return web_write_all(ws->fd, hdr, 6 + len); -} - -static char *web_ws_read_message(cdp_ws *ws, char *err, size_t err_len) { - web_buf msg = {0}; - for (;;) { - unsigned char h[2]; - if (web_read_exact(ws, h, 2, DS4_WEB_CDP_TIMEOUT_MS, err, err_len) != 0) { - free(msg.ptr); - return NULL; - } - bool fin = (h[0] & 0x80) != 0; - int opcode = h[0] & 0x0f; - bool masked = (h[1] & 0x80) != 0; - uint64_t len = h[1] & 0x7f; - if (len == 126) { - unsigned char x[2]; - if (web_read_exact(ws, x, 2, DS4_WEB_CDP_TIMEOUT_MS, err, err_len) != 0) goto fail; - len = ((uint64_t)x[0] << 8) | x[1]; - } else if (len == 127) { - unsigned char x[8]; - if (web_read_exact(ws, x, 8, DS4_WEB_CDP_TIMEOUT_MS, err, err_len) != 0) goto fail; - len = 0; - for (int i = 0; i < 8; i++) len = (len << 8) | x[i]; - } - unsigned char mask[4] = {0}; - if (masked && web_read_exact(ws, mask, 4, DS4_WEB_CDP_TIMEOUT_MS, err, err_len) != 0) - goto fail; - if (len > DS4_WEB_MAX_RESULT_BYTES * 4ULL) { - web_set_err(err, err_len, "websocket message too large"); - free(msg.ptr); - return NULL; - } - unsigned char *payload = web_xmalloc((size_t)len + 1); - if (len && web_read_exact(ws, payload, (size_t)len, - DS4_WEB_CDP_TIMEOUT_MS, err, err_len) != 0) { - free(payload); - goto fail; - } - for (uint64_t i = 0; masked && i < len; i++) payload[i] ^= mask[i & 3]; - payload[len] = '\0'; - if (opcode == 0x8) { - free(payload); - web_set_err(err, err_len, "websocket closed"); - free(msg.ptr); - return NULL; - } else if (opcode == 0x9) { - web_ws_send_pong(ws, payload, (size_t)len); - free(payload); - continue; - } else if (opcode == 0x1 || opcode == 0x0) { - web_buf_append(&msg, (const char *)payload, (size_t)len); - free(payload); - if (fin) return web_buf_take(&msg); - } else { - free(payload); - } - } -fail: - if (err && err_len && !err[0]) web_set_err(err, err_len, "websocket frame read failed"); - free(msg.ptr); - return NULL; -} - -static bool web_json_id_matches(const char *json, int id) { - const char *p = strstr(json, "\"id\""); - if (!p) return false; - p = strchr(p, ':'); - if (!p) return false; - p++; - while (*p == ' ' || *p == '\t') p++; - return atoi(p) == id; -} - -static char *web_cdp_call(cdp_ws *ws, const char *method, const char *params, - char *err, size_t err_len) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return NULL; - int id = ws->next_id++; - web_buf req = {0}; - char head[256]; - snprintf(head, sizeof(head), "{\"id\":%d,\"method\":", id); - web_buf_puts(&req, head); - char *qmethod = web_json_quote(method); - web_buf_puts(&req, qmethod); - free(qmethod); - if (params && params[0]) { - web_buf_puts(&req, ",\"params\":"); - web_buf_puts(&req, params); - } - web_buf_puts(&req, "}"); - char *wire = web_buf_take(&req); - if (web_ws_send_text(ws, wire, err, err_len) != 0) { - free(wire); - return NULL; - } - free(wire); - for (;;) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return NULL; - char *msg = web_ws_read_message(ws, err, err_len); - if (!msg) return NULL; - if (web_json_id_matches(msg, id)) return msg; - free(msg); - } -} - -static void web_cdp_call_optional(cdp_ws *ws, const char *method, const char *params) { - char err[160] = {0}; - char *resp = web_cdp_call(ws, method, params, err, sizeof(err)); - free(resp); -} - -static int web_hex4(const char *p) { - int v = 0; - for (int i = 0; i < 4; i++) { - char c = p[i]; - int x; - if (c >= '0' && c <= '9') x = c - '0'; - else if (c >= 'a' && c <= 'f') x = c - 'a' + 10; - else if (c >= 'A' && c <= 'F') x = c - 'A' + 10; - else return -1; - v = (v << 4) | x; - } - return v; -} - -static void web_utf8_append(web_buf *b, unsigned code) { - char out[4]; - if (code <= 0x7f) { - out[0] = (char)code; - web_buf_append(b, out, 1); - } else if (code <= 0x7ff) { - out[0] = (char)(0xc0 | (code >> 6)); - out[1] = (char)(0x80 | (code & 0x3f)); - web_buf_append(b, out, 2); - } else if (code <= 0xffff) { - out[0] = (char)(0xe0 | (code >> 12)); - out[1] = (char)(0x80 | ((code >> 6) & 0x3f)); - out[2] = (char)(0x80 | (code & 0x3f)); - web_buf_append(b, out, 3); - } else { - out[0] = (char)(0xf0 | (code >> 18)); - out[1] = (char)(0x80 | ((code >> 12) & 0x3f)); - out[2] = (char)(0x80 | ((code >> 6) & 0x3f)); - out[3] = (char)(0x80 | (code & 0x3f)); - web_buf_append(b, out, 4); - } -} - -static char *web_json_parse_string_at(const char *q, const char **endp) { - if (*q != '"') return NULL; - q++; - web_buf b = {0}; - while (*q && *q != '"') { - if (*q != '\\') { - web_buf_append(&b, q++, 1); - continue; - } - q++; - switch (*q) { - case '"': web_buf_append(&b, "\"", 1); q++; break; - case '\\': web_buf_append(&b, "\\", 1); q++; break; - case '/': web_buf_append(&b, "/", 1); q++; break; - case 'b': web_buf_append(&b, "\b", 1); q++; break; - case 'f': web_buf_append(&b, "\f", 1); q++; break; - case 'n': web_buf_append(&b, "\n", 1); q++; break; - case 'r': web_buf_append(&b, "\r", 1); q++; break; - case 't': web_buf_append(&b, "\t", 1); q++; break; - case 'u': { - int v = web_hex4(q + 1); - if (v < 0) { free(b.ptr); return NULL; } - q += 5; - if (v >= 0xd800 && v <= 0xdbff && q[0] == '\\' && q[1] == 'u') { - int lo = web_hex4(q + 2); - if (lo >= 0xdc00 && lo <= 0xdfff) { - unsigned code = 0x10000 + (((unsigned)v - 0xd800) << 10) + - ((unsigned)lo - 0xdc00); - web_utf8_append(&b, code); - q += 6; - break; - } - } - web_utf8_append(&b, (unsigned)v); - break; - } - default: - if (*q) web_buf_append(&b, q++, 1); - break; - } - } - if (*q != '"') { - free(b.ptr); - return NULL; - } - if (endp) *endp = q + 1; - return web_buf_take(&b); -} - -static char *web_json_get_string(const char *json, const char *key) { - char pat[128]; - snprintf(pat, sizeof(pat), "\"%s\"", key); - const char *p = json; - while ((p = strstr(p, pat)) != NULL) { - p += strlen(pat); - while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; - if (*p++ != ':') continue; - while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; - if (*p == '"') return web_json_parse_string_at(p, NULL); - } - return NULL; -} - -static char *web_cdp_eval_string(cdp_ws *ws, const char *expr, - char *err, size_t err_len) { - char *qexpr = web_json_quote(expr); - web_buf params = {0}; - web_buf_puts(¶ms, "{\"expression\":"); - web_buf_puts(¶ms, qexpr); - web_buf_puts(¶ms, ",\"returnByValue\":true,\"awaitPromise\":true,\"includeCommandLineAPI\":true}"); - free(qexpr); - char *params_s = web_buf_take(¶ms); - char *resp = web_cdp_call(ws, "Runtime.evaluate", params_s, err, err_len); - free(params_s); - if (!resp) return NULL; - if (strstr(resp, "\"exceptionDetails\"")) { - web_set_err(err, err_len, "JavaScript evaluation failed"); - free(resp); - return NULL; - } - char *val = web_json_get_string(resp, "value"); - free(resp); - if (!val) web_set_err(err, err_len, "Runtime.evaluate did not return a string"); - return val; -} - -static bool web_wait_ready(cdp_ws *ws, char *err, size_t err_len) { - const char *expr = "document.readyState"; - for (int i = 0; i < 80; i++) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return false; - char *state = web_cdp_eval_string(ws, expr, err, err_len); - if (state && (!strcmp(state, "complete") || !strcmp(state, "interactive"))) { - free(state); - if (web_sleep_ms(ws ? ws->web : NULL, 800)) return true; - web_set_err(err, err_len, "interrupted"); - return false; - } - free(state); - if (!web_sleep_ms(ws ? ws->web : NULL, 250)) { - web_set_err(err, err_len, "interrupted"); - return false; - } - } - return true; -} - -static bool web_cdp_navigate(cdp_ws *ws, const char *url, - char *err, size_t err_len) { - char *qurl = web_json_quote(url); - web_buf params = {0}; - web_buf_puts(¶ms, "{\"url\":"); - web_buf_puts(¶ms, qurl); - web_buf_puts(¶ms, "}"); - free(qurl); - char *params_s = web_buf_take(¶ms); - char *resp = web_cdp_call(ws, "Page.navigate", params_s, err, err_len); - free(params_s); - if (!resp) return false; - free(resp); - return true; -} - -static bool web_page_probe(cdp_ws *ws, char **href_out, char **ready_out, - long *text_len_out, char *err, size_t err_len) { - const char *expr = - "location.href+'\\n'+document.readyState+'\\n'+" - "((document.body&&document.body.innerText)||'').length"; - char *probe = web_cdp_eval_string(ws, expr, err, err_len); - if (!probe) return false; - - char *nl1 = strchr(probe, '\n'); - char *nl2 = nl1 ? strchr(nl1 + 1, '\n') : NULL; - if (!nl1 || !nl2) { - free(probe); - web_set_err(err, err_len, "page readiness probe returned malformed data"); - return false; - } - *nl1 = '\0'; - *nl2 = '\0'; - if (href_out) *href_out = web_xstrdup(probe); - if (ready_out) *ready_out = web_xstrdup(nl1 + 1); - if (text_len_out) *text_len_out = strtol(nl2 + 1, NULL, 10); - free(probe); - return true; -} - -static bool web_wait_navigated_ready(cdp_ws *ws, const char *url, - char *err, size_t err_len) { - (void)url; - long last_len = -1; - int stable = 0; - bool saw_real_url = false; - - for (int i = 0; i < 100; i++) { - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return false; - char *href = NULL; - char *ready = NULL; - long text_len = 0; - bool ok = web_page_probe(ws, &href, &ready, &text_len, err, err_len); - if (!ok) { - free(href); - free(ready); - if (!web_sleep_ms(ws ? ws->web : NULL, 250)) { - web_set_err(err, err_len, "interrupted"); - return false; - } - continue; - } - - bool real_url = href && href[0] && - strcmp(href, "about:blank") && - strncmp(href, "chrome://", 9); - bool ready_state = ready && - (!strcmp(ready, "complete") || !strcmp(ready, "interactive")); - if (real_url) saw_real_url = true; - if (text_len > 0 && text_len == last_len) stable++; - else stable = 0; - last_len = text_len; - - free(href); - free(ready); - - if (saw_real_url && ready_state && text_len > 0 && stable >= 2) { - if (web_sleep_ms(ws ? ws->web : NULL, 500)) return true; - web_set_err(err, err_len, "interrupted"); - return false; - } - if (saw_real_url && ready_state && i >= 24) return true; - if (!web_sleep_ms(ws ? ws->web : NULL, 250)) { - web_set_err(err, err_len, "interrupted"); - return false; - } - } - return true; -} - -static bool web_cdp_prepare_page(cdp_ws *ws, char *err, size_t err_len) { - char *resp = web_cdp_call(ws, "Page.enable", "{}", err, err_len); - if (!resp) return false; - free(resp); - resp = web_cdp_call(ws, "Runtime.enable", "{}", err, err_len); - if (!resp) return false; - free(resp); - web_cdp_call_optional(ws, "Emulation.setFocusEmulationEnabled", - "{\"enabled\":true}"); - web_cdp_call_optional(ws, "Emulation.setDeviceMetricsOverride", - "{\"width\":1365,\"height\":900,\"deviceScaleFactor\":1,\"mobile\":false}"); - return web_wait_ready(ws, err, err_len); -} - -static bool web_scroll_dynamic_page(cdp_ws *ws, char *err, size_t err_len) { - const char *expr = - "(() => new Promise(resolve => {" - "const root=()=>document.scrollingElement||document.documentElement||document.body;" - "const blockSel='h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id=\"content-text\"],[class*=\"comment-body\"],[class*=\"comment-content\"],[data-testid*=\"comment-text\"]';" - "const lazySel='[onscroll],[loading=\"lazy\"],[data-src],[data-lazy],[class*=\"lazy\"],[class*=\"infinite\"],[class*=\"virtual\"],[role=\"feed\"],[id*=\"comment\"],[class*=\"comment\"],[data-testid*=\"comment\"]';" - "const hookCount=()=>{let n=0;try{if(window.onscroll)n++;if(document.onscroll)n++;if(document.body&&document.body.onscroll)n++;}catch(e){}" - "try{if(typeof getEventListeners==='function'){for(const o of [window,document,document.body]){if(!o)continue;const ev=getEventListeners(o);if(ev&&ev.scroll)n+=ev.scroll.length;}}}catch(e){}" - "try{n+=document.querySelectorAll(lazySel).length;}catch(e){}return n;};" - "const metrics=()=>{const r=root();return {" - "height:r?r.scrollHeight:0," - "view:innerHeight||900," - "y:scrollY||(r&&r.scrollTop)||0," - "text:((document.body&&document.body.innerText)||'').length," - "links:document.links?document.links.length:0," - "blocks:document.body?document.body.querySelectorAll(blockSel).length:0," - "hooks:hookCount()};};" - "const sig=m=>[m.height,m.text,m.links,m.blocks].join('|');" - "const grew=(a,b)=>b.height>a.height+20||b.text>a.text+200||b.links>a.links+2||b.blocks>a.blocks+2;" - "const scrollOnce=()=>{const r=root();if(!r)return;" - "const h=Math.max(700,Math.floor((innerHeight||900)*0.85));" - "window.scrollTo(0,Math.min(r.scrollHeight,(scrollY||r.scrollTop||0)+h));};" - "let last=metrics(),lastSig=sig(last),same=0,steps=0;" - "const scrollable=last.height>last.view*1.35;" - "if(!scrollable||last.hooks===0){resolve('scroll skipped hooks='+last.hooks+' text='+last.text);return;}" - "const tick=()=>{" - "if(steps>=28){resolve('scrolled '+steps+' text='+last.text);return;}" - "const before=last;" - "scrollOnce();steps++;" - "setTimeout(()=>{const now=metrics(),nowSig=sig(now);" - "if(nowSig===lastSig)same++;else same=0;" - "const loaded=grew(before,now);" - "last=now;lastSig=nowSig;" - "if(steps===1&&!loaded){resolve('scroll probe unchanged text='+now.text);return;}" - "const atBottom=now.y+now.view+20>=now.height;" - "if(same>=4||(atBottom&&same>=1)){resolve('scrolled '+steps+' text='+now.text);return;}" - "tick();},900);" - "};tick();" - "}))()"; - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return false; - - char local_err[160] = {0}; - char *res = web_cdp_eval_string(ws, expr, local_err, sizeof(local_err)); - if (!res && web_err_is_interrupted(local_err)) { - web_set_err(err, err_len, "interrupted"); - return false; - } - free(res); - if (web_set_cancel_err(ws ? ws->web : NULL, err, err_len)) return false; - return true; -} - -static char *web_chrome_executable(void) { - const char *env = getenv("DS4_CHROME"); - if (env && env[0]) return web_xstrdup(env); -#ifdef __APPLE__ - if (access("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", X_OK) == 0) - return web_xstrdup("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); - if (access("/Applications/Chromium.app/Contents/MacOS/Chromium", X_OK) == 0) - return web_xstrdup("/Applications/Chromium.app/Contents/MacOS/Chromium"); -#endif - const char *paths[] = { - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/snap/bin/chromium", - "/opt/google/chrome/chrome", - NULL - }; - for (int i = 0; paths[i]; i++) { - if (access(paths[i], X_OK) == 0) return web_xstrdup(paths[i]); - } - - const char *names[] = { - "google-chrome", - "google-chrome-stable", - "chromium", - "chromium-browser", - NULL - }; - const char *pathenv = getenv("PATH"); - if (pathenv) { - char *path = web_xstrdup(pathenv); - char *save = NULL; - for (char *dir = strtok_r(path, ":", &save); dir; dir = strtok_r(NULL, ":", &save)) { - for (int i = 0; names[i]; i++) { - char candidate[PATH_MAX]; - snprintf(candidate, sizeof(candidate), "%s/%s", dir[0] ? dir : ".", names[i]); - if (access(candidate, X_OK) == 0) { - char *res = web_xstrdup(candidate); - free(path); - return res; - } - } - } - free(path); - } - return web_xstrdup("google-chrome"); -} - -#ifdef __APPLE__ -static const char *web_macos_chrome_app_name(void) { - if (getenv("DS4_CHROME")) return NULL; - if (access("/Applications/Google Chrome.app", F_OK) == 0) - return "Google Chrome"; - if (access("/Applications/Chromium.app", F_OK) == 0) - return "Chromium"; - return NULL; -} -#endif - -static bool web_spawn_chrome(ds4_web *web, char *err, size_t err_len) { - if (!web_mkdir_p(web->profile_dir)) { - web_set_err(err, err_len, "failed to create Chrome profile dir %s: %s", - web->profile_dir, strerror(errno)); - return false; - } - char *exe = web_chrome_executable(); -#ifdef __APPLE__ - const char *mac_app_name = web_macos_chrome_app_name(); - bool launched_via_open = mac_app_name != NULL && access("/usr/bin/open", X_OK) == 0; -#else - bool launched_via_open = false; -#endif - char port_arg[64], profile_arg[PATH_MAX + 64]; - snprintf(port_arg, sizeof(port_arg), "--remote-debugging-port=%d", web->port); - snprintf(profile_arg, sizeof(profile_arg), "--user-data-dir=%s", web->profile_dir); - pid_t pid = fork(); - if (pid < 0) { - web_set_err(err, err_len, "failed to fork Chrome: %s", strerror(errno)); - free(exe); - return false; - } - if (pid == 0) { - int nullfd = open("/dev/null", O_RDWR); - if (nullfd >= 0) { - dup2(nullfd, STDOUT_FILENO); - dup2(nullfd, STDERR_FILENO); - if (nullfd > 2) close(nullfd); - } -#ifdef __APPLE__ - if (launched_via_open) { - execlp("/usr/bin/open", "open", "-g", "-na", mac_app_name, - "--args", port_arg, "--remote-allow-origins=*", - profile_arg, "--no-first-run", "--no-default-browser-check", - "--disable-sync", "--use-mock-keychain", "--password-store=basic", - "--mute-audio", "about:blank", (char *)NULL); - } else { - execlp(exe, exe, port_arg, "--remote-allow-origins=*", - profile_arg, "--no-first-run", "--no-default-browser-check", - "--disable-sync", "--use-mock-keychain", "--password-store=basic", - "--mute-audio", "about:blank", (char *)NULL); - } -#else - if (geteuid() == 0) { - execlp(exe, exe, port_arg, "--remote-allow-origins=*", - profile_arg, "--no-first-run", "--no-default-browser-check", - "--disable-sync", "--password-store=basic", "--no-sandbox", - "--mute-audio", "about:blank", (char *)NULL); - } else { - execlp(exe, exe, port_arg, "--remote-allow-origins=*", - profile_arg, "--no-first-run", "--no-default-browser-check", - "--disable-sync", "--password-store=basic", - "--mute-audio", "about:blank", (char *)NULL); - } -#endif - _exit(127); - } - free(exe); - web->chrome_pid = pid; - for (int i = 0; i < 80; i++) { - if (web_set_cancel_err(web, err, err_len)) return false; - if (web_cdp_alive(web)) { - web_log(web, "Chrome browser session is ready"); - return true; - } - int status = 0; - pid_t rc = waitpid(pid, &status, WNOHANG); - if (rc == pid) { - web->chrome_pid = 0; - if (launched_via_open) continue; - web_set_err(err, err_len, "Chrome exited before CDP became ready"); - return false; - } - if (!web_sleep_ms(web, 250)) { - web_set_err(err, err_len, "interrupted"); - return false; - } - } - web_set_err(err, err_len, "Chrome did not expose CDP on port %d", web->port); - return false; -} - -static bool web_ensure_browser(ds4_web *web, char *err, size_t err_len) { - if (web_cdp_alive(web)) return true; - if (web->chrome_pid > 0) { - int status = 0; - waitpid(web->chrome_pid, &status, WNOHANG); - web->chrome_pid = 0; - } - if (!web->browser_allowed) { - if (!web->confirm) { - web_set_err(err, err_len, - "starting a visible Chrome browser requires interactive approval"); - return false; - } - if (!web->confirm(web->confirm_privdata, - "The web tool wants to start a visible Chrome browser. Allow? (y/n) ", - err, err_len)) - { - if (err && !err[0]) web_set_err(err, err_len, "user denied Chrome browser start"); - return false; - } - web->browser_allowed = true; - } - return web_spawn_chrome(web, err, err_len); -} - -static void web_tab_free(web_tab *tab) { - if (!tab) return; - free(tab->id); - free(tab->ws_url); - tab->id = NULL; - tab->ws_url = NULL; -} - -static char *web_browser_ws_url(ds4_web *web, char *err, size_t err_len) { - char *body = web_http_request("GET", web->port, "/json/version", err, err_len); - if (!body) return NULL; - char *ws = web_json_get_string(body, "webSocketDebuggerUrl"); - free(body); - if (!ws) web_set_err(err, err_len, "Chrome did not return a browser WebSocket URL"); - return ws; -} - -static bool web_open_tab(ds4_web *web, const char *url, web_tab *tab, - char *err, size_t err_len) { - memset(tab, 0, sizeof(*tab)); - - char *browser_url = web_browser_ws_url(web, err, err_len); - if (!browser_url) return false; - cdp_ws browser = {.fd = -1, .web = web}; - if (web_ws_connect(browser_url, &browser, err, err_len) != 0) { - free(browser_url); - return false; - } - free(browser_url); - - char *qurl = web_json_quote(url); - web_buf params = {0}; - web_buf_puts(¶ms, "{\"url\":"); - web_buf_puts(¶ms, qurl); - web_buf_puts(¶ms, ",\"background\":true,\"newWindow\":false}"); - free(qurl); - char *params_s = web_buf_take(¶ms); - char *resp = web_cdp_call(&browser, "Target.createTarget", - params_s, err, err_len); - free(params_s); - web_ws_close(&browser); - if (!resp) return false; - - tab->id = web_json_get_string(resp, "targetId"); - free(resp); - if (!tab->id) { - web_tab_free(tab); - web_set_err(err, err_len, "Chrome did not return a page target id"); - return false; - } - - char ws_url[PATH_MAX + 128]; - snprintf(ws_url, sizeof(ws_url), "ws://127.0.0.1:%d/devtools/page/%s", - web->port, tab->id); - tab->ws_url = web_xstrdup(ws_url); - return true; -} - -static void web_close_tab(ds4_web *web, const web_tab *tab) { - if (!web || !tab || !tab->id || !tab->id[0]) return; - char *enc = web_url_encode(tab->id); - web_buf path = {0}; - web_buf_puts(&path, "/json/close/"); - web_buf_puts(&path, enc); - free(enc); - - char err[160] = {0}; - char *path_s = web_buf_take(&path); - char *body = web_http_request("GET", web->port, path_s, err, sizeof(err)); - free(path_s); - if (body) { - free(body); - } else if (err[0]) { - web_log(web, err); - } -} - -static const char *web_click_google_consent_js = -"(() => {" -"const clean=s=>(s||'').replace(/\\s+/g,' ').trim();" -"const pats=[/accept all/i,/i agree/i,/agree/i,/accetta tutto/i,/tout accepter/i,/aceptar todo/i,/alle akzeptieren/i];" -"const els=[...document.querySelectorAll('button,[role=button],input[type=submit],a')];" -"for (const el of els){const t=clean(el.innerText||el.value||el.textContent);" -"if(!t)continue; if(pats.some(p=>p.test(t))){el.click(); return 'clicked '+t;}}" -"return '';" -"})()"; - -static const char *web_extract_search_js = -"(() => {" -"const clean=s=>(s||'').replace(/\\s+/g,' ').trim();" -"const esc=s=>clean(s).replace(/\\\\/g,'\\\\\\\\').replace(/\\[/g,'\\\\[').replace(/\\]/g,'\\\\]').replace(/\\n/g,' ');" -"const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';};" -"const bad=h=>(/(^|\\.)google\\./.test(h)||/(^|\\.)gstatic\\./.test(h)||/(^|\\.)googleusercontent\\./.test(h));" -"const lines=['# Google search results','',`URL: ${location.href}`,'','## Visible links'];" -"const seen=new Set();" -"for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;let href=a.href||'';" -"try{const u=new URL(href);if(u.pathname==='/url'&&u.searchParams.get('q'))href=u.searchParams.get('q');}catch{}" -"let u;try{u=new URL(href);}catch{continue;}if(!/^https?:$/.test(u.protocol))continue;if(bad(u.hostname))continue;" -"const text=esc(a.innerText||a.textContent);if(text.length<3)continue;if(seen.has(u.href))continue;seen.add(u.href);" -"lines.push(`- [${text.slice(0,180)}](${u.href})`);if(seen.size>=20)break;}" -"lines.push('','## Text snapshot',clean(document.body.innerText).slice(0,1200));" -"return lines.join('\\n');" -"})()"; - -static const char *web_extract_page_js = -"(() => {" -"const clean=s=>(s||'').replace(/\\s+/g,' ').trim();" -"const esc=s=>clean(s).replace(/\\\\/g,'\\\\\\\\').replace(/\\[/g,'\\\\[').replace(/\\]/g,'\\\\]').replace(/\\n/g,' ');" -"const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';};" -"const inline=n=>{if(!n)return'';if(n.nodeType===3)return n.nodeValue;if(n.nodeType!==1)return'';const el=n;" -"if(el.tagName==='SCRIPT'||el.tagName==='STYLE'||el.tagName==='NOSCRIPT')return'';" -"if(el.tagName==='A'){const t=esc(el.innerText||el.textContent);const h=el.href||'';return t&&h?`[${t}](${h})`:t;}" -"if(el.tagName==='CODE')return '`'+clean(el.innerText||el.textContent).replace(/`/g,'\\\\`')+'`';" -"return [...el.childNodes].map(inline).join('');};" -"const lines=[`# ${clean(document.title)||location.href}`,'',`URL: ${location.href}`,'','## Content'];" -"const blocks=[...document.body.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id=\"content-text\"],[class*=\"comment-body\"],[class*=\"comment-content\"],[data-testid*=\"comment-text\"]')];" -"const seen=new Set();" -"for(const el of blocks){if(!visible(el))continue;let s='';const tag=el.tagName;" -"if(/^H[1-6]$/.test(tag)){s='#'.repeat(Number(tag[1]))+' '+inline(el);}" -"else if(tag==='LI'){s='- '+inline(el);}" -"else if(tag==='PRE'){s='```\\n'+(el.innerText||el.textContent||'').trimEnd()+'\\n```';}" -"else if(tag==='BLOCKQUOTE'){s='> '+clean(el.innerText||el.textContent);}" -"else{s=inline(el);}s=s.trim();if(!s||seen.has(s))continue;seen.add(s);lines.push('',s);" -"if(lines.join('\\n').length>900000){lines.push('','[Content truncated by browser extractor.]');break;}}" -"lines.push('','## Visible links');let n=0;const linkSeen=new Set();" -"for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;const t=esc(a.innerText||a.textContent);if(t.length<3)continue;" -"let u;try{u=new URL(a.href);}catch{continue;}if(!/^https?:$/.test(u.protocol)||linkSeen.has(u.href))continue;linkSeen.add(u.href);" -"lines.push(`- [${t.slice(0,160)}](${u.href})`);if(++n>=80)break;}" -"return lines.join('\\n');" -"})()"; - -static char *web_run_page_js(ds4_web *web, const char *url, const char *js, - bool dynamic_scroll, - char *err, size_t err_len) { - if (!web_ensure_browser(web, err, err_len)) return NULL; - web_tab tab = {0}; - if (!web_open_tab(web, "about:blank", &tab, err, err_len)) return NULL; - cdp_ws ws = {.fd = -1, .web = web}; - if (web_ws_connect(tab.ws_url, &ws, err, err_len) != 0) { - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - if (!web_cdp_prepare_page(&ws, err, err_len)) { - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - if (!web_cdp_navigate(&ws, url, err, err_len) || - !web_wait_navigated_ready(&ws, url, err, err_len)) - { - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - char *clicked = web_cdp_eval_string(&ws, web_click_google_consent_js, err, err_len); - if (clicked && clicked[0]) { - web_log(web, clicked); - if (!web_sleep_ms(web, 1500)) { - free(clicked); - web_set_err(err, err_len, "interrupted"); - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - char wait_err[160] = {0}; - if (!web_wait_navigated_ready(&ws, url, wait_err, sizeof(wait_err)) && - web_err_is_interrupted(wait_err)) - { - free(clicked); - web_set_err(err, err_len, "interrupted"); - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - } - free(clicked); - if (dynamic_scroll && !web_scroll_dynamic_page(&ws, err, err_len)) { - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return NULL; - } - char *out = web_cdp_eval_string(&ws, js, err, err_len); - web_ws_close(&ws); - web_close_tab(web, &tab); - web_tab_free(&tab); - return out; -} - -ds4_web *ds4_web_create(const ds4_web_config *cfg) { - ds4_web *web = web_xmalloc(sizeof(*web)); - memset(web, 0, sizeof(*web)); - const char *profile = cfg && cfg->profile_dir && cfg->profile_dir[0] ? - cfg->profile_dir : "browser"; - snprintf(web->profile_dir, sizeof(web->profile_dir), "%s", profile); - web->port = cfg && cfg->port > 0 ? cfg->port : DS4_WEB_DEFAULT_PORT; - web->chrome_pid = 0; - web->next_cdp_id = 1; - if (cfg) { - web->confirm = cfg->confirm; - web->confirm_privdata = cfg->confirm_privdata; - web->log = cfg->log; - web->log_privdata = cfg->log_privdata; - web->cancel = cfg->cancel; - web->cancel_privdata = cfg->cancel_privdata; - } - return web; -} - -void ds4_web_free(ds4_web *web) { - if (!web) return; - /* Do not kill Chrome. The browser profile is user-visible state and keeping - * it alive makes repeated web tool calls cheaper and less suspicious. */ - free(web); -} - -char *ds4_web_google_search(ds4_web *web, const char *query, - char *err, size_t err_len) { - if (!web) { - web_set_err(err, err_len, "web subsystem is not initialized"); - return NULL; - } - if (!query || !query[0]) { - web_set_err(err, err_len, "google_search requires query"); - return NULL; - } - char *q = web_url_encode(query); - web_buf url = {0}; - web_buf_puts(&url, "https://www.google.com/search?q="); - web_buf_puts(&url, q); - free(q); - char *url_s = web_buf_take(&url); - char *out = web_run_page_js(web, url_s, web_extract_search_js, false, err, err_len); - free(url_s); - return out; -} - -char *ds4_web_visit_page(ds4_web *web, const char *url, - char *err, size_t err_len) { - if (!web) { - web_set_err(err, err_len, "web subsystem is not initialized"); - return NULL; - } - if (!url || !url[0]) { - web_set_err(err, err_len, "visit_page requires url"); - return NULL; - } - return web_run_page_js(web, url, web_extract_page_js, true, err, err_len); -} diff --git a/native/web/ds4_web.h b/native/web/ds4_web.h deleted file mode 100644 index 8ef1441..0000000 --- a/native/web/ds4_web.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef DS4_WEB_H -#define DS4_WEB_H - -#include -#include - -typedef int (*ds4_web_confirm_fn)(void *privdata, const char *message, - char *err, size_t err_len); -typedef void (*ds4_web_log_fn)(void *privdata, const char *message); -typedef bool (*ds4_web_cancel_fn)(void *privdata); - -typedef struct { - const char *profile_dir; - int port; - ds4_web_confirm_fn confirm; - void *confirm_privdata; - ds4_web_log_fn log; - void *log_privdata; - ds4_web_cancel_fn cancel; - void *cancel_privdata; -} ds4_web_config; - -typedef struct ds4_web ds4_web; - -ds4_web *ds4_web_create(const ds4_web_config *cfg); -void ds4_web_free(ds4_web *web); - -char *ds4_web_google_search(ds4_web *web, const char *query, - char *err, size_t err_len); -char *ds4_web_visit_page(ds4_web *web, const char *url, - char *err, size_t err_len); - -#endif diff --git a/src/agent.rs b/src/agent.rs index 749d04c..4db1ce7 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1,12 +1,14 @@ +mod web; + +use self::web::Browser; use crate::model::ModelChoice; use serde_json::{Map, Value}; use std::collections::HashMap; -use std::ffi::{CStr, CString, c_char, c_int, c_void}; use std::fs::{self, File}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError}; use std::sync::{Arc, Mutex}; use std::thread; @@ -30,158 +32,8 @@ const SHELL_ENV_ALLOWLIST: &[&str] = &[ ]; pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after context compaction."; -#[repr(C)] -struct WebConfig { - profile_dir: *const c_char, - port: c_int, - confirm: Option c_int>, - confirm_data: *mut c_void, - log: Option, - log_data: *mut c_void, - cancel: Option bool>, - cancel_data: *mut c_void, -} - -unsafe extern "C" { - fn ds4_web_create(config: *const WebConfig) -> *mut c_void; - fn ds4_web_free(web: *mut c_void); - fn ds4_web_google_search( - web: *mut c_void, - query: *const c_char, - error: *mut c_char, - error_len: usize, - ) -> *mut c_char; - fn ds4_web_visit_page( - web: *mut c_void, - url: *const c_char, - error: *mut c_char, - error_len: usize, - ) -> *mut c_char; - fn free(pointer: *mut c_void); -} - -struct WebCallbacks { - cancel: AtomicPtr, -} - -struct Browser { - web: *mut c_void, - callbacks: Box, -} - -// The C browser is used by one agent-tools worker at a time under Tools' mutex. -unsafe impl Send for Browser {} - -impl Browser { - fn new() -> Result { - let mut callbacks = Box::new(WebCallbacks { - cancel: AtomicPtr::new(std::ptr::null_mut()), - }); - let profile = CString::new( - crate::app::browser_profile_path() - .to_string_lossy() - .as_bytes(), - ) - .map_err(|_| "The browser profile path contains a NUL byte.".to_owned())?; - let data = (&mut *callbacks) as *mut WebCallbacks as *mut c_void; - let config = WebConfig { - profile_dir: profile.as_ptr(), - port: 9333, - confirm: Some(web_confirm), - confirm_data: data, - log: None, - log_data: std::ptr::null_mut(), - cancel: Some(web_cancel), - cancel_data: data, - }; - let web = unsafe { ds4_web_create(&config) }; - if web.is_null() { - Err("Could not initialize browser tools.".into()) - } else { - Ok(Self { web, callbacks }) - } - } - - fn google_search(&self, query: &str, cancel: &AtomicBool) -> Result { - self.call(query, cancel, ds4_web_google_search) - } - - fn visit_page(&self, url: &str, cancel: &AtomicBool) -> Result { - self.call(url, cancel, ds4_web_visit_page) - } - - fn call( - &self, - value: &str, - cancel: &AtomicBool, - operation: unsafe extern "C" fn( - *mut c_void, - *const c_char, - *mut c_char, - usize, - ) -> *mut c_char, - ) -> Result { - let value = CString::new(value).map_err(|_| "web input contains a NUL byte".to_owned())?; - let mut error = [0 as c_char; 256]; - self.callbacks.cancel.store( - cancel as *const AtomicBool as *mut AtomicBool, - Ordering::Relaxed, - ); - let result = - unsafe { operation(self.web, value.as_ptr(), error.as_mut_ptr(), error.len()) }; - self.callbacks - .cancel - .store(std::ptr::null_mut(), Ordering::Relaxed); - if result.is_null() { - let error = unsafe { CStr::from_ptr(error.as_ptr()) } - .to_string_lossy() - .into_owned(); - Err(if error.is_empty() { - "browser tool failed".into() - } else { - error - }) - } else { - let output = unsafe { CStr::from_ptr(result) } - .to_string_lossy() - .into_owned(); - unsafe { free(result.cast()) }; - Ok(output) - } - } -} - -impl Drop for Browser { - fn drop(&mut self) { - unsafe { ds4_web_free(self.web) }; - } -} - -unsafe extern "C" fn web_confirm( - _data: *mut c_void, - message: *const c_char, - error: *mut c_char, - error_len: usize, -) -> c_int { - let _ = message; - let _ = error; - let _ = error_len; - // Approval is obtained by the shared tool worker before entering C. Keeping - // this callback lets the reference browser retain its launch guard. - 1 -} - -unsafe extern "C" fn web_cancel(data: *mut c_void) -> bool { - if data.is_null() { - return false; - } - let callbacks = unsafe { &*(data.cast::()) }; - let cancel = callbacks.cancel.load(Ordering::Relaxed); - !cancel.is_null() && unsafe { (*cancel).load(Ordering::Relaxed) } -} - -const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a visible browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}} -{"type":"function","function":{"name":"visit_page","description":"Open a URL in a visible browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}} +const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}} +{"type":"function","function":{"name":"visit_page","description":"Open a URL in a browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}} {"type":"function","function":{"name":"bash","description":"Run a shell command.","parameters":{"type":"object","properties":{"command":{"type":"string"},"timeout_sec":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["command"]}}} {"type":"function","function":{"name":"bash_status","description":"Report current status and new output for a bash job.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}} {"type":"function","function":{"name":"bash_stop","description":"Terminate a running bash job and report its final output.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}} @@ -315,7 +167,7 @@ impl Tools { more_text: None, jobs: HashMap::new(), next_job: 1, - browser: Browser::new()?, + browser: Browser::new(), dev_brain: None, }) } @@ -944,8 +796,8 @@ impl Tools { fn approval(&self, call: &ToolCall) -> Option { if matches!(call.name.as_str(), "google_search" | "visit_page") { return Some(ApprovalPrompt { - title: "Allow visible browser?".into(), - detail: "Start a visible Chrome browser for this tool call.".into(), + title: "Allow browser?".into(), + detail: "Start headless Chrome for this tool call.".into(), working_directory: self.root.clone(), }); } @@ -1345,7 +1197,7 @@ pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> ) } else { format!( - "You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<|DSML|tool_calls>\n<|DSML|invoke name=\"$TOOL_NAME\">\n<|DSML|parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE\n\n\n\nTool calls are not allowed inside . String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start visible Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise." + "You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<|DSML|tool_calls>\n<|DSML|invoke name=\"$TOOL_NAME\">\n<|DSML|parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE\n\n\n\nTool calls are not allowed inside . String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start headless Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise." ) }; let tools = if dev_brain { diff --git a/src/agent/web.rs b/src/agent/web.rs new file mode 100644 index 0000000..63e5f54 --- /dev/null +++ b/src/agent/web.rs @@ -0,0 +1,480 @@ +use headless_chrome::browser::{default_executable, tab::Tab}; +use headless_chrome::protocol::cdp::{Emulation, Runtime}; +use headless_chrome::{Browser as Chrome, LaunchOptions}; +use serde_json::Value; +use std::ffi::OsStr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::Duration; + +const PORT: u16 = 9333; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const CDP_TIMEOUT: Duration = Duration::from_secs(20); +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +const CLICK_GOOGLE_CONSENT_JS: &str = r#"(() => { +const clean=s=>(s||'').replace(/\s+/g,' ').trim(); +const pats=[/accept all/i,/i agree/i,/agree/i,/accetta tutto/i,/tout accepter/i,/aceptar todo/i,/alle akzeptieren/i]; +const els=[...document.querySelectorAll('button,[role=button],input[type=submit],a')]; +for (const el of els){const t=clean(el.innerText||el.value||el.textContent);if(!t)continue;if(pats.some(p=>p.test(t))){el.click();return 'clicked '+t;}} +return ''; +})()"#; + +const EXTRACT_SEARCH_JS: &str = r#"(() => { +const clean=s=>(s||'').replace(/\s+/g,' ').trim(); +const esc=s=>clean(s).replace(/\\/g,'\\\\').replace(/\[/g,'\\[').replace(/\]/g,'\\]').replace(/\n/g,' '); +const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';}; +const bad=h=>(/(^|\.)google\./.test(h)||/(^|\.)gstatic\./.test(h)||/(^|\.)googleusercontent\./.test(h)); +const lines=['# Google search results','',`URL: ${location.href}`,'','## Visible links']; +const seen=new Set(); +for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;let href=a.href||'';try{const u=new URL(href);if(u.pathname==='/url'&&u.searchParams.get('q'))href=u.searchParams.get('q');}catch{}let u;try{u=new URL(href);}catch{continue;}if(!/^https?:$/.test(u.protocol))continue;if(bad(u.hostname))continue;const text=esc(a.innerText||a.textContent);if(text.length<3)continue;if(seen.has(u.href))continue;seen.add(u.href);lines.push(`- [${text.slice(0,180)}](${u.href})`);if(seen.size>=20)break;} +lines.push('','## Text snapshot',clean(document.body.innerText).slice(0,1200)); +return lines.join('\n'); +})()"#; + +const EXTRACT_PAGE_JS: &str = r#"(() => { +const clean=s=>(s||'').replace(/\s+/g,' ').trim(); +const esc=s=>clean(s).replace(/\\/g,'\\\\').replace(/\[/g,'\\[').replace(/\]/g,'\\]').replace(/\n/g,' '); +const visible=el=>{const r=el.getBoundingClientRect();const st=getComputedStyle(el);return r.width>0&&r.height>0&&st.display!=='none'&&st.visibility!=='hidden'&&st.opacity!=='0';}; +const inline=n=>{if(!n)return'';if(n.nodeType===3)return n.nodeValue;if(n.nodeType!==1)return'';const el=n;if(el.tagName==='SCRIPT'||el.tagName==='STYLE'||el.tagName==='NOSCRIPT')return'';if(el.tagName==='A'){const t=esc(el.innerText||el.textContent);const h=el.href||'';return t&&h?`[${t}](${h})`:t;}if(el.tagName==='CODE')return '`'+clean(el.innerText||el.textContent).replace(/`/g,'\\`')+'`';return [...el.childNodes].map(inline).join('');}; +const lines=[`# ${clean(document.title)||location.href}`,'',`URL: ${location.href}`,'','## Content']; +const blocks=[...document.body.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]')]; +const seen=new Set(); +for(const el of blocks){if(!visible(el))continue;let s='';const tag=el.tagName;if(/^H[1-6]$/.test(tag)){s='#'.repeat(Number(tag[1]))+' '+inline(el);}else if(tag==='LI'){s='- '+inline(el);}else if(tag==='PRE'){s='```\n'+(el.innerText||el.textContent||'').trimEnd()+'\n```';}else if(tag==='BLOCKQUOTE'){s='> '+clean(el.innerText||el.textContent);}else{s=inline(el);}s=s.trim();if(!s||seen.has(s))continue;seen.add(s);lines.push('',s);if(lines.join('\n').length>900000){lines.push('','[Content truncated by browser extractor.]');break;}} +lines.push('','## Visible links');let n=0;const linkSeen=new Set(); +for(const a of document.querySelectorAll('a[href]')){if(!visible(a))continue;const t=esc(a.innerText||a.textContent);if(t.length<3)continue;let u;try{u=new URL(a.href);}catch{continue;}if(!/^https?:$/.test(u.protocol)||linkSeen.has(u.href))continue;linkSeen.add(u.href);lines.push(`- [${t.slice(0,160)}](${u.href})`);if(++n>=80)break;} +return lines.join('\n'); +})()"#; + +const SCROLL_DYNAMIC_PAGE_JS: &str = r#"(() => new Promise(resolve => { +const root=()=>document.scrollingElement||document.documentElement||document.body; +const blockSel='h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,th,[id="content-text"],[class*="comment-body"],[class*="comment-content"],[data-testid*="comment-text"]'; +const lazySel='[onscroll],[loading="lazy"],[data-src],[data-lazy],[class*="lazy"],[class*="infinite"],[class*="virtual"],[role="feed"],[id*="comment"],[class*="comment"],[data-testid*="comment"]'; +const hookCount=()=>{let n=0;try{if(window.onscroll)n++;if(document.onscroll)n++;if(document.body&&document.body.onscroll)n++;}catch(e){}try{if(typeof getEventListeners==='function'){for(const o of [window,document,document.body]){if(!o)continue;const ev=getEventListeners(o);if(ev&&ev.scroll)n+=ev.scroll.length;}}catch(e){}try{n+=document.querySelectorAll(lazySel).length;}catch(e){}return n;}; +const metrics=()=>{const r=root();return {height:r?r.scrollHeight:0,view:innerHeight||900,y:scrollY||(r&&r.scrollTop)||0,text:((document.body&&document.body.innerText)||'').length,links:document.links?document.links.length:0,blocks:document.body?document.body.querySelectorAll(blockSel).length:0,hooks:hookCount()};}; +const sig=m=>[m.height,m.text,m.links,m.blocks].join('|'); +const grew=(a,b)=>b.height>a.height+20||b.text>a.text+200||b.links>a.links+2||b.blocks>a.blocks+2; +const scrollOnce=()=>{const r=root();if(!r)return;const h=Math.max(700,Math.floor((innerHeight||900)*0.85));window.scrollTo(0,Math.min(r.scrollHeight,(scrollY||r.scrollTop||0)+h));}; +let last=metrics(),lastSig=sig(last),same=0,steps=0; +const scrollable=last.height>last.view*1.35; +if(!scrollable||last.hooks===0){resolve('scroll skipped hooks='+last.hooks+' text='+last.text);return;} +const tick=()=>{if(steps>=28){resolve('scrolled '+steps+' text='+last.text);return;}const before=last;scrollOnce();steps++;setTimeout(()=>{const now=metrics(),nowSig=sig(now);if(nowSig===lastSig)same++;else same=0;const loaded=grew(before,now);last=now;lastSig=nowSig;if(steps===1&&!loaded){resolve('scroll probe unchanged text='+now.text);return;}const atBottom=now.y+now.view+20>=now.height;if(same>=4||(atBottom&&same>=1)){resolve('scrolled '+steps+' text='+now.text);return;}tick();},900);}; +tick(); +}))()"#; + +pub(super) struct Browser { + chrome: Option, + profile_dir: PathBuf, +} + +impl Browser { + pub(super) fn new() -> Self { + Self { + chrome: None, + profile_dir: crate::app::browser_profile_path(), + } + } + + pub(super) fn google_search( + &mut self, + query: &str, + cancel: &AtomicBool, + ) -> Result { + if query.is_empty() { + return Err("google_search requires query".into()); + } + self.run_page(&google_search_url(query), EXTRACT_SEARCH_JS, false, cancel) + } + + pub(super) fn visit_page(&mut self, url: &str, cancel: &AtomicBool) -> Result { + if url.is_empty() { + return Err("visit_page requires url".into()); + } + self.run_page(url, EXTRACT_PAGE_JS, true, cancel) + } + + fn run_page( + &mut self, + url: &str, + extract: &str, + dynamic_scroll: bool, + cancel: &AtomicBool, + ) -> Result { + let chrome = self.ensure_browser(cancel)?.clone(); + let tab = new_tab(chrome, cancel)?; + tab.set_default_timeout(CDP_TIMEOUT); + + let result = (|| { + prepare_page(&tab, cancel)?; + check_cancel(cancel)?; + tab.navigate_to(url) + .map_err(|error| format!("page navigation failed: {error}"))?; + wait_navigated_ready(&tab, cancel)?; + + if let Ok(clicked) = evaluate_string(&tab, CLICK_GOOGLE_CONSENT_JS, cancel) + && !clicked.is_empty() + { + sleep(cancel, Duration::from_millis(1500))?; + wait_navigated_ready(&tab, cancel)?; + } + if dynamic_scroll { + scroll_dynamic_page(&tab, cancel)?; + } + evaluate_string(&tab, extract, cancel) + })(); + + close_tab(tab, cancel.load(Ordering::Relaxed)); + result + } + + fn ensure_browser(&mut self, cancel: &AtomicBool) -> Result<&Chrome, String> { + if self.chrome.is_none() { + let profile_dir = self.profile_dir.clone(); + self.chrome = Some(background(cancel, move || start_browser(profile_dir))?); + } + Ok(self.chrome.as_ref().unwrap()) + } +} + +fn start_browser(profile_dir: PathBuf) -> Result { + if let Some(browser) = connect_existing() { + return Ok(browser); + } + if profile_dir.to_str().is_none() { + return Err("The browser profile path is not valid UTF-8.".into()); + } + std::fs::create_dir_all(&profile_dir) + .map_err(|error| format!("failed to create Chrome profile dir: {error}"))?; + let executable = std::env::var_os("DS4_CHROME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .map_or_else(default_executable, Ok) + .map_err(|error| format!("could not find Chrome: {error}"))?; + let args = [ + OsStr::new("--remote-allow-origins=*"), + OsStr::new("--no-default-browser-check"), + OsStr::new("--disable-sync"), + OsStr::new("--use-mock-keychain"), + OsStr::new("--password-store=basic"), + OsStr::new("--mute-audio"), + OsStr::new("about:blank"), + ]; + let options = LaunchOptions::default_builder() + .headless(true) + .path(Some(executable)) + .user_data_dir(Some(profile_dir)) + .port(Some(PORT)) + .idle_browser_timeout(CDP_TIMEOUT) + .ignore_certificate_errors(false) + .enable_gpu(true) + .disable_default_args(true) + .args(args.to_vec()) + .build() + .map_err(|error| format!("could not configure Chrome: {error}"))?; + Chrome::new(options).map_err(|error| format!("could not start Chrome: {error}")) +} + +fn connect_existing() -> Option { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_recv_response(Some(CONNECT_TIMEOUT)) + .timeout_recv_body(Some(CONNECT_TIMEOUT)) + .build() + .into(); + let mut response = agent + .get(&format!("http://127.0.0.1:{PORT}/json/version")) + .call() + .ok()?; + let body = response.body_mut().read_to_string().ok()?; + let websocket = serde_json::from_str::(&body) + .ok()? + .get("webSocketDebuggerUrl")? + .as_str()? + .to_owned(); + Chrome::connect_with_timeout(websocket, CDP_TIMEOUT).ok() +} + +fn new_tab(chrome: Chrome, cancel: &AtomicBool) -> Result, String> { + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let result = chrome + .new_tab() + .map_err(|error| format!("could not open browser tab: {error}")); + if let Err(mpsc::SendError(Ok(tab))) = sender.send(result) { + let _ = tab.close_target(); + } + }); + wait(receiver, cancel) +} + +fn prepare_page(tab: &Arc, cancel: &AtomicBool) -> Result<(), String> { + check_cancel(cancel)?; + tab.call_method(Runtime::Enable(None)) + .map_err(|error| format!("could not enable page runtime: {error}"))?; + let _ = tab.call_method(Emulation::SetFocusEmulationEnabled { enabled: true }); + let _ = tab.call_method(Emulation::SetDeviceMetricsOverride { + width: 1365, + height: 900, + device_scale_factor: 1.0, + mobile: false, + scale: None, + screen_width: None, + screen_height: None, + position_x: None, + position_y: None, + dont_set_visible_size: None, + screen_orientation: None, + viewport: None, + display_feature: None, + device_posture: None, + }); + wait_ready(tab, cancel) +} + +fn wait_ready(tab: &Arc, cancel: &AtomicBool) -> Result<(), String> { + for _ in 0..80 { + check_cancel(cancel)?; + if let Ok(state) = evaluate_string(tab, "document.readyState", cancel) + && matches!(state.as_str(), "complete" | "interactive") + { + return sleep(cancel, Duration::from_millis(800)); + } + sleep(cancel, Duration::from_millis(250))?; + } + Ok(()) +} + +fn wait_navigated_ready(tab: &Arc, cancel: &AtomicBool) -> Result<(), String> { + let mut last_len = -1; + let mut stable = 0; + let mut saw_real_url = false; + for iteration in 0..100 { + check_cancel(cancel)?; + let Ok(probe) = evaluate_string( + tab, + "location.href+'\\n'+document.readyState+'\\n'+((document.body&&document.body.innerText)||'').length", + cancel, + ) else { + sleep(cancel, Duration::from_millis(250))?; + continue; + }; + let Ok((href, ready, text_len)) = parse_probe(&probe) else { + sleep(cancel, Duration::from_millis(250))?; + continue; + }; + let real_url = !href.is_empty() && href != "about:blank" && !href.starts_with("chrome://"); + let ready = matches!(ready, "complete" | "interactive"); + saw_real_url |= real_url; + if text_len > 0 && text_len == last_len { + stable += 1; + } else { + stable = 0; + } + last_len = text_len; + if saw_real_url && ready && text_len > 0 && stable >= 2 { + return sleep(cancel, Duration::from_millis(500)); + } + if saw_real_url && ready && iteration >= 24 { + return Ok(()); + } + sleep(cancel, Duration::from_millis(250))?; + } + Ok(()) +} + +fn scroll_dynamic_page(tab: &Arc, cancel: &AtomicBool) -> Result<(), String> { + check_cancel(cancel)?; + let result = evaluate_string(tab, SCROLL_DYNAMIC_PAGE_JS, cancel); + check_cancel(cancel)?; + match result { + Err(error) if error == "interrupted" => Err(error), + _ => Ok(()), + } +} + +fn evaluate_string( + tab: &Arc, + expression: &str, + cancel: &AtomicBool, +) -> Result { + let tab = Arc::clone(tab); + let expression = expression.to_owned(); + background(cancel, move || { + let response = tab + .call_method(Runtime::Evaluate { + expression, + object_group: None, + include_command_line_api: Some(true), + silent: None, + context_id: None, + return_by_value: Some(true), + generate_preview: None, + user_gesture: None, + await_promise: Some(true), + throw_on_side_effect: None, + timeout: None, + disable_breaks: None, + repl_mode: None, + allow_unsafe_eval_blocked_by_csp: None, + unique_context_id: None, + serialization_options: None, + }) + .map_err(|error| format!("JavaScript evaluation failed: {error}"))?; + if response.exception_details.is_some() { + return Err("JavaScript evaluation failed".into()); + } + response + .result + .value + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| "Runtime.evaluate did not return a string".into()) + }) +} + +fn background(cancel: &AtomicBool, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let _ = sender.send(operation()); + }); + wait(receiver, cancel) +} + +fn wait(receiver: mpsc::Receiver>, cancel: &AtomicBool) -> Result { + loop { + match receiver.recv_timeout(POLL_INTERVAL) { + Ok(result) => return result, + Err(mpsc::RecvTimeoutError::Timeout) => check_cancel(cancel)?, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("browser worker stopped unexpectedly".into()); + } + } + } +} + +fn close_tab(tab: Arc, background: bool) { + if background { + thread::spawn(move || { + let _ = tab.close_target(); + }); + } else { + let _ = tab.close_target(); + } +} + +fn check_cancel(cancel: &AtomicBool) -> Result<(), String> { + if cancel.load(Ordering::Relaxed) { + Err("interrupted".into()) + } else { + Ok(()) + } +} + +fn sleep(cancel: &AtomicBool, duration: Duration) -> Result<(), String> { + let mut remaining = duration; + while !remaining.is_zero() { + check_cancel(cancel)?; + let step = remaining.min(POLL_INTERVAL); + thread::sleep(step); + remaining = remaining.saturating_sub(step); + } + check_cancel(cancel) +} + +fn parse_probe(probe: &str) -> Result<(&str, &str, i64), String> { + let mut parts = probe.splitn(3, '\n'); + let href = parts.next().unwrap_or_default(); + let ready = parts + .next() + .ok_or_else(|| "page readiness probe returned malformed data".to_owned())?; + let text_len = parts + .next() + .ok_or_else(|| "page readiness probe returned malformed data".to_owned())? + .parse() + .unwrap_or(0); + Ok((href, ready, text_len)) +} + +fn google_search_url(query: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut url = String::from("https://www.google.com/search?q="); + for byte in query.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + url.push(char::from(byte)); + } else { + url.push('%'); + url.push(char::from(HEX[(byte >> 4) as usize])); + url.push(char::from(HEX[(byte & 15) as usize])); + } + } + url +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + #[test] + fn browser_helpers_preserve_ds4_wire_values() { + assert_eq!( + google_search_url("café & Rust~"), + "https://www.google.com/search?q=caf%C3%A9%20%26%20Rust~" + ); + assert_eq!( + parse_probe("https://example.com\ncomplete\n42").unwrap(), + ("https://example.com", "complete", 42) + ); + assert!(parse_probe("incomplete").is_err()); + } + + #[test] + #[ignore = "requires an installed Chrome browser"] + fn headless_browser_extracts_a_local_page() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + let _ = stream.read(&mut request); + let body = "Fixture

Hello

Browser works.

"; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + let profile_dir = std::env::temp_dir().join(format!( + "ds4-browser-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut browser = Browser { + chrome: None, + profile_dir: profile_dir.clone(), + }; + let output = browser + .run_page( + &format!("http://{address}"), + EXTRACT_PAGE_JS, + false, + &AtomicBool::new(false), + ) + .unwrap(); + assert!(output.contains("# Fixture")); + assert!(output.contains("# Hello")); + assert!(output.contains("Browser works.")); + drop(browser); + server.join().unwrap(); + std::fs::remove_dir_all(profile_dir).unwrap(); + } +}