From eeea15c65dafa292779a8ac5a7aa6a73cac9a4ca Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:01:09 +0400 Subject: [PATCH] feat: move background processes to its own daemon --- src-tauri/Cargo.lock | 1001 ++++++++++++++++++++++-- src-tauri/Cargo.toml | 17 +- src-tauri/copy-proxy-binary.sh | 78 +- src-tauri/donut-browser-daemon | 0 src-tauri/src/api_server.rs | 12 +- src-tauri/src/app_auto_updater.rs | 16 +- src-tauri/src/auto_updater.rs | 5 +- src-tauri/src/bin/donut_daemon.rs | 401 ++++++++++ src-tauri/src/browser_runner.rs | 34 +- src-tauri/src/camoufox/config.rs | 4 +- src-tauri/src/camoufox/geolocation.rs | 107 +-- src-tauri/src/commercial_license.rs | 7 +- src-tauri/src/daemon/autostart.rs | 247 ++++++ src-tauri/src/daemon/mod.rs | 3 + src-tauri/src/daemon/services.rs | 51 ++ src-tauri/src/daemon/tray.rs | 150 ++++ src-tauri/src/daemon_client.rs | 152 ++++ src-tauri/src/daemon_ws.rs | 134 ++++ src-tauri/src/downloader.rs | 12 +- src-tauri/src/events/mod.rs | 171 ++++ src-tauri/src/extraction.rs | 6 +- src-tauri/src/geoip_downloader.rs | 10 +- src-tauri/src/group_manager.rs | 13 +- src-tauri/src/ip_utils.rs | 122 +++ src-tauri/src/lib.rs | 75 +- src-tauri/src/mcp_server.rs | 189 ++++- src-tauri/src/profile/manager.rs | 50 +- src-tauri/src/proxy_manager.rs | 111 +-- src-tauri/src/settings_manager.rs | 198 ++++- src-tauri/src/sync/engine.rs | 68 +- src-tauri/src/sync/scheduler.rs | 24 +- src-tauri/src/sync/subscription.rs | 6 +- src-tauri/src/version_updater.rs | 14 +- src-tauri/tauri.conf.json | 2 +- src/app/page.tsx | 14 + src/components/home-header.tsx | 12 +- src/components/integrations-dialog.tsx | 390 +++++++++ src/components/profile-data-table.tsx | 69 +- src/components/settings-dialog.tsx | 439 +---------- 39 files changed, 3466 insertions(+), 948 deletions(-) create mode 100644 src-tauri/donut-browser-daemon create mode 100644 src-tauri/src/bin/donut_daemon.rs create mode 100644 src-tauri/src/daemon/autostart.rs create mode 100644 src-tauri/src/daemon/mod.rs create mode 100644 src-tauri/src/daemon/services.rs create mode 100644 src-tauri/src/daemon/tray.rs create mode 100644 src-tauri/src/daemon_client.rs create mode 100644 src-tauri/src/daemon_ws.rs create mode 100644 src-tauri/src/events/mod.rs create mode 100644 src-tauri/src/ip_utils.rs create mode 100644 src/components/integrations-dialog.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7597143..05809b8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -75,6 +75,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -181,6 +199,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "argon2" version = "0.5.3" @@ -205,6 +234,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ashpd" version = "0.11.0" @@ -412,6 +450,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.15.2" @@ -441,6 +522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -459,8 +541,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -504,6 +588,12 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -519,6 +609,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -562,13 +661,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -638,6 +746,12 @@ dependencies = [ "serde", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.1" @@ -690,6 +804,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -941,6 +1061,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1058,6 +1184,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1100,6 +1235,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1322,9 +1476,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -1394,7 +1548,9 @@ dependencies = [ "chrono", "clap", "core-foundation 0.10.1", + "crossbeam-channel", "directories", + "dirs", "env_logger", "flate2", "futures-util", @@ -1402,6 +1558,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "image", "lazy_static", "libc", "log", @@ -1409,9 +1566,10 @@ dependencies = [ "maxminddb", "mime_guess", "msi-extract", + "muda 0.15.3", "nix 0.29.0", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "once_cell", "playwright", "quick-xml 0.37.5", @@ -1423,7 +1581,9 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "single-instance", "sysinfo", + "tao", "tar", "tauri", "tauri-build", @@ -1438,9 +1598,10 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.27.0", "tower", "tower-http", + "tray-icon 0.19.3", "url", "urlencoding", "utoipa", @@ -1579,6 +1740,26 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1627,6 +1808,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1645,6 +1841,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1669,7 +1885,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -2049,6 +2265,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2229,6 +2455,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2471,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2582,6 +2819,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.0", + "zune-jpeg 0.5.8", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "1.9.3" @@ -2623,6 +2900,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2828,6 +3116,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2864,6 +3158,16 @@ version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2896,6 +3200,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "libz-rs-sys" version = "0.5.5" @@ -2935,6 +3258,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3043,12 +3375,31 @@ dependencies = [ "serde", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3095,6 +3446,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "msi" version = "0.8.0" @@ -3119,6 +3480,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "muda" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdae9c00e61cc0579bcac625e8ad22104c60548a025bfc972dc83868a28e1484" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", + "png 0.17.16", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + [[package]] name = "muda" version = "0.17.1" @@ -3129,12 +3510,12 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -3193,6 +3574,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.29.0" @@ -3215,7 +3609,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3224,6 +3618,21 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "ntapi" version = "0.4.2" @@ -3233,12 +3642,53 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3289,6 +3739,22 @@ dependencies = [ "libc", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -3299,6 +3765,22 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -3306,18 +3788,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-cloud-kit", - "objc2-core-data", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.2", "objc2-core-text", "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -3327,8 +3809,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.10.0", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -3338,8 +3832,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.10.0", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -3350,7 +3844,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -3361,19 +3855,31 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -3383,7 +3889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", ] @@ -3395,7 +3901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -3416,6 +3922,18 @@ dependencies = [ "cc", ] +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -3423,9 +3941,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -3446,7 +3964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -3456,10 +3974,35 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3467,9 +4010,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -3479,7 +4022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -3490,9 +4033,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -3502,11 +4045,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.10.0", - "block2", - "objc2", - "objc2-app-kit", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-javascript-core", "objc2-security", ] @@ -3698,6 +4241,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3932,6 +4481,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4077,6 +4639,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.111", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4097,6 +4678,30 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -4303,12 +4908,82 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4496,17 +5171,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ "ashpd", - "block2", + "block2 0.6.2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -4514,6 +5189,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.17.14" @@ -5173,12 +5854,34 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "single-instance" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4637485391f8545c9d3dbf60f9d9aab27a90c789a700999677583bcb17c8795d" +dependencies = [ + "libc", + "nix 0.23.2", + "thiserror 1.0.69", + "widestring", + "winapi", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5222,11 +5925,11 @@ dependencies = [ "bytemuck", "js-sys", "ndk", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", "tracing", @@ -5427,7 +6130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", @@ -5444,9 +6147,9 @@ dependencies = [ "ndk", "ndk-context", "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "parking_lot", "raw-window-handle", @@ -5515,10 +6218,10 @@ dependencies = [ "libc", "log", "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "muda 0.17.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", @@ -5537,7 +6240,7 @@ dependencies = [ "tauri-utils", "thiserror 2.0.17", "tokio", - "tray-icon", + "tray-icon 0.21.3", "url", "webkit2gtk", "webview2-com", @@ -5578,7 +6281,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5696,8 +6399,8 @@ dependencies = [ "byte-unit", "fern", "log", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "serde", "serde_json", "serde_repr", @@ -5715,8 +6418,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5607e0707d37d7b20e287cf0ce396d1efebe7b833b8e9cbd2ea4257091d9c604" dependencies = [ "macos-accessibility-client", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "serde", "tauri", "tauri-plugin", @@ -5731,8 +6434,8 @@ checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" dependencies = [ "dunce", "glob", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "open", "schemars 0.8.22", "serde", @@ -5793,7 +6496,7 @@ dependencies = [ "gtk", "http", "jni", - "objc2", + "objc2 0.6.3", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -5817,9 +6520,9 @@ dependencies = [ "http", "jni", "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -5947,6 +6650,20 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "time" version = "0.3.44" @@ -6086,7 +6803,19 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", ] [[package]] @@ -6286,6 +7015,27 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tray-icon" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd75f5002e2513eaa19b2365f533090cc3e93abd38788452d9ea85cff7b48a" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda 0.15.3", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.17", + "windows-sys 0.59.0", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -6295,14 +7045,14 @@ dependencies = [ "crossbeam-channel", "dirs", "libappindicator", - "muda", - "objc2", - "objc2-app-kit", + "muda 0.17.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -6332,6 +7082,23 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6350,7 +7117,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -6539,6 +7306,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "value-bag" version = "1.12.0" @@ -6863,6 +7641,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "widestring" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" + [[package]] name = "winapi" version = "0.3.9" @@ -6900,10 +7690,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -7440,7 +8230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64 0.22.1", - "block2", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs", @@ -7455,10 +8245,10 @@ dependencies = [ "kuchikiki", "libc", "ndk", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -7527,6 +8317,12 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -7816,6 +8612,45 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +dependencies = [ + "zune-core 0.5.0", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f044ae3..f46e9e7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,10 @@ path = "src/main.rs" name = "donut-proxy" path = "src/bin/proxy_server.rs" +[[bin]] +name = "donut-daemon" +path = "src/bin/donut_daemon.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -65,7 +69,7 @@ mime_guess = "2" once_cell = "1" urlencoding = "2.1" chrono = { version = "0.4", features = ["serde"] } -axum = "0.8.8" +axum = { version = "0.8.8", features = ["ws"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors"] } rand = "0.9.2" @@ -92,6 +96,15 @@ tempfile = "3" maxminddb = "0.24" quick-xml = { version = "0.37", features = ["serialize"] } +# Daemon dependencies (tray icon) +tray-icon = "0.19" +muda = "0.15" +tao = "0.34" +single-instance = "0.3" +image = "0.25" +dirs = "6" +crossbeam-channel = "0.5" + [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } @@ -101,7 +114,7 @@ nix = { version = "0.29", features = ["signal", "process"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" objc2 = "0.6.1" -objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] } +objc2-app-kit = { version = "0.3.1", features = ["NSWindow", "NSApplication", "NSRunningApplication"] } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" diff --git a/src-tauri/copy-proxy-binary.sh b/src-tauri/copy-proxy-binary.sh index 53e3e68..a23f49f 100755 --- a/src-tauri/copy-proxy-binary.sh +++ b/src-tauri/copy-proxy-binary.sh @@ -5,13 +5,6 @@ set -e TARGET="${TARGET:-$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "unknown")}" MANIFEST_DIR="$(dirname "$0")" -# Determine binary name based on target -if [[ "$TARGET" == *"windows"* ]]; then - BIN_NAME="donut-proxy.exe" -else - BIN_NAME="donut-proxy" -fi - # Determine source path HOST_TARGET=$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "$TARGET") if [[ "$TARGET" == "$HOST_TARGET" ]] || [[ "$TARGET" == "unknown" ]]; then @@ -30,40 +23,59 @@ else fi fi -SOURCE="$SRC_DIR/$BIN_NAME" DEST_DIR="$MANIFEST_DIR/binaries" -# Tauri expects the format: donut-proxy-{target} with hyphens -DEST_NAME="donut-proxy-$TARGET" -if [[ "$TARGET" == *"windows"* ]]; then - DEST_NAME="$DEST_NAME.exe" -fi -DEST="$DEST_DIR/$DEST_NAME" - # Create binaries directory if it doesn't exist mkdir -p "$DEST_DIR" -# Copy the binary if it exists -if [[ -f "$SOURCE" ]]; then - cp "$SOURCE" "$DEST" - echo "Copied $BIN_NAME to $DEST" -else - echo "Warning: Binary not found at $SOURCE" - echo "Building donut-proxy binary..." - cd "$MANIFEST_DIR" - BUILD_ARGS=("build" "--bin" "donut-proxy") - if [[ -n "$PROFILE" ]] && [[ "$PROFILE" == "release" ]]; then - BUILD_ARGS+=("--release") +# Function to copy a binary +copy_binary() { + local BIN_BASE_NAME="$1" + + # Determine binary name based on target + if [[ "$TARGET" == *"windows"* ]]; then + BIN_NAME="${BIN_BASE_NAME}.exe" + else + BIN_NAME="$BIN_BASE_NAME" fi - if [[ -n "$TARGET" ]] && [[ "$TARGET" != "unknown" ]] && [[ "$TARGET" != "$HOST_TARGET" ]]; then - BUILD_ARGS+=("--target" "$TARGET") + + SOURCE="$SRC_DIR/$BIN_NAME" + + # Tauri expects the format: binary-{target} with hyphens + DEST_NAME="${BIN_BASE_NAME}-$TARGET" + if [[ "$TARGET" == *"windows"* ]]; then + DEST_NAME="$DEST_NAME.exe" fi - cargo "${BUILD_ARGS[@]}" + DEST="$DEST_DIR/$DEST_NAME" + + # Copy the binary if it exists if [[ -f "$SOURCE" ]]; then cp "$SOURCE" "$DEST" - echo "Built and copied $BIN_NAME to $DEST" + echo "Copied $BIN_NAME to $DEST" else - echo "Error: Failed to build donut-proxy binary" - exit 1 + echo "Warning: Binary not found at $SOURCE" + echo "Building $BIN_BASE_NAME binary..." + cd "$MANIFEST_DIR" + BUILD_ARGS=("build" "--bin" "$BIN_BASE_NAME") + if [[ -n "$PROFILE" ]] && [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=("--release") + fi + if [[ -n "$TARGET" ]] && [[ "$TARGET" != "unknown" ]] && [[ "$TARGET" != "$HOST_TARGET" ]]; then + BUILD_ARGS+=("--target" "$TARGET") + fi + cargo "${BUILD_ARGS[@]}" + if [[ -f "$SOURCE" ]]; then + cp "$SOURCE" "$DEST" + echo "Built and copied $BIN_NAME to $DEST" + else + echo "Error: Failed to build $BIN_BASE_NAME binary" + exit 1 + fi fi -fi +} + +# Copy donut-proxy binary +copy_binary "donut-proxy" + +# Copy donut-daemon binary +copy_binary "donut-daemon" diff --git a/src-tauri/donut-browser-daemon b/src-tauri/donut-browser-daemon new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 797fdbb..be0f04a 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1,5 +1,7 @@ use crate::browser::ProxySettings; use crate::camoufox_manager::CamoufoxConfig; +use crate::daemon_ws::{ws_handler, WsState}; +use crate::events; use crate::group_manager::GROUP_MANAGER; use crate::profile::manager::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; @@ -15,7 +17,6 @@ use axum::{ use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::Emitter; use tokio::net::TcpListener; use tokio::sync::{mpsc, Mutex}; use tower_http::cors::CorsLayer; @@ -276,7 +277,7 @@ impl ApiServer { let random_port = rand::random::().saturating_add(10000); match TcpListener::bind(format!("127.0.0.1:{random_port}")).await { Ok(listener) => { - let _ = app_handle.emit( + let _ = events::emit( "api-port-conflict", format!("API server using fallback port {random_port}"), ); @@ -329,8 +330,15 @@ impl ApiServer { )) .layer(middleware::from_fn(terms_check_middleware)); + // Create WebSocket route with its own state (no auth required for daemon IPC) + let ws_state = WsState::new(); + let ws_routes = Router::new() + .route("/events", get(ws_handler)) + .with_state(ws_state); + let app = Router::new() .nest("/v1", v1_routes) + .nest("/ws", ws_routes) .route("/openapi.json", get(move || async move { Json(api) })) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index ff9df3d..b35d52c 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -64,13 +64,13 @@ Includes comprehensive unit tests for: - File format support */ +use crate::events; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; -use tauri::Emitter; #[cfg(target_os = "linux")] #[derive(Debug, Clone)] @@ -707,7 +707,7 @@ impl AppAutoUpdater { .to_string(); // Emit download start event - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "downloading".to_string(), @@ -724,7 +724,7 @@ impl AppAutoUpdater { .await?; // Emit extraction start event - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "extracting".to_string(), @@ -739,7 +739,7 @@ impl AppAutoUpdater { let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; // Emit installation start event - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "installing".to_string(), @@ -757,7 +757,7 @@ impl AppAutoUpdater { let _ = fs::remove_dir_all(&temp_dir); // Emit completion event - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "completed".to_string(), @@ -780,7 +780,7 @@ impl AppAutoUpdater { download_url: &str, dest_dir: &Path, filename: &str, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, ) -> Result> { let file_path = dest_dir.join(filename); @@ -853,7 +853,7 @@ impl AppAutoUpdater { "Unknown".to_string() }; - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "downloading".to_string(), @@ -869,7 +869,7 @@ impl AppAutoUpdater { } // Emit final download completion - let _ = app_handle.emit( + let _ = events::emit( "app-update-progress", AppUpdateProgress { stage: "downloading".to_string(), diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 05bb498..fac2f33 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -1,11 +1,11 @@ use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager}; +use crate::events; use crate::profile::{BrowserProfile, ProfileManager}; use crate::settings_manager::SettingsManager; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; -use tauri::Emitter; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UpdateNotification { @@ -199,8 +199,7 @@ impl AutoUpdater { "affected_profiles": affected_profiles }); - if let Err(e) = - app_handle_clone.emit("browser-auto-update-available", &auto_update_event) + if let Err(e) = events::emit("browser-auto-update-available", &auto_update_event) { log::error!("Failed to emit auto-update event for {browser}: {e}"); } else { diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs new file mode 100644 index 0000000..bfa93a5 --- /dev/null +++ b/src-tauri/src/bin/donut_daemon.rs @@ -0,0 +1,401 @@ +// Donut Browser Daemon - Background process for tray icon and services +// This runs independently of the main Tauri GUI + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process; +use std::sync::atomic::{AtomicBool, Ordering}; + +use muda::MenuEvent; +use serde::{Deserialize, Serialize}; +use single_instance::SingleInstance; +use tao::event::{Event, StartCause}; +use tao::event_loop::{ControlFlow, EventLoopBuilder}; +use tokio::runtime::Runtime; +use tray_icon::TrayIcon; + +use donutbrowser_lib::daemon::{autostart, services, tray}; + +static SHOULD_QUIT: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct DaemonState { + daemon_pid: Option, + api_port: Option, + mcp_running: bool, + version: String, +} + +fn get_state_path() -> PathBuf { + autostart::get_data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("daemon-state.json") +} + +fn ensure_data_dir() -> std::io::Result<()> { + if let Some(data_dir) = autostart::get_data_dir() { + fs::create_dir_all(&data_dir)?; + } + Ok(()) +} + +fn read_state() -> DaemonState { + let path = get_state_path(); + if path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(state) = serde_json::from_str(&content) { + return state; + } + } + } + DaemonState::default() +} + +fn write_state(state: &DaemonState) -> std::io::Result<()> { + let path = get_state_path(); + let content = serde_json::to_string_pretty(state)?; + fs::write(path, content) +} + +fn detach_from_parent() { + #[cfg(unix)] + { + unsafe { + libc::setsid(); + } + } +} + +fn spawn_detached() { + #[cfg(unix)] + { + match unsafe { libc::fork() } { + -1 => { + eprintln!("Fork failed"); + process::exit(1); + } + 0 => { + detach_from_parent(); + } + _ => { + process::exit(0); + } + } + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + let current_exe = env::current_exe().expect("Failed to get current exe path"); + + let _ = Command::new(current_exe) + .arg("--daemon-internal") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .spawn(); + + process::exit(0); + } +} + +fn set_high_priority() { + #[cfg(unix)] + { + // Set high priority so the daemon is killed last under resource pressure + // Negative nice value = higher priority. Try -10, fall back to -5 if it fails. + unsafe { + if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 { + let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5); + } + } + } + + #[cfg(windows)] + { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, + }; + + // Set high priority so the daemon is killed last under resource pressure + unsafe { + let handle = GetCurrentProcess(); + let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS); + // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed, + // but we do it anyway for consistency + let _ = CloseHandle(handle); + } + } +} + +fn run_daemon() { + // Set high priority so the daemon is less likely to be killed under resource pressure + set_high_priority(); + + // Initialize logging + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Info) + .format_timestamp_millis() + .init(); + + if let Err(e) = ensure_data_dir() { + eprintln!("Failed to create data directory: {}", e); + process::exit(1); + } + + let instance = + SingleInstance::new("donut-browser-daemon").expect("Failed to create single instance lock"); + if !instance.is_single() { + eprintln!("Daemon is already running"); + process::exit(1); + } + + log::info!("[daemon] Starting with PID {}", process::id()); + + // Create tokio runtime for async operations + let rt = Runtime::new().expect("Failed to create tokio runtime"); + + // Start services in the background + let services_result = rt.block_on(async { services::DaemonServices::start().await }); + + let daemon_services = match services_result { + Ok(s) => s, + Err(e) => { + log::error!("Failed to start services: {}", e); + process::exit(1); + } + }; + + // Write initial state + let state = DaemonState { + daemon_pid: Some(process::id()), + api_port: daemon_services.api_port, + mcp_running: daemon_services.mcp_running, + version: env!("CARGO_PKG_VERSION").to_string(), + }; + if let Err(e) = write_state(&state) { + log::error!("Failed to write state: {}", e); + } + + // Prepare tray menu and icon (but don't create the tray icon yet) + let tray_menu = tray::TrayMenu::new(); + tray_menu.update_api_status(daemon_services.api_port); + tray_menu.update_mcp_status(daemon_services.mcp_running); + + let icon = tray::load_icon(); + let menu_channel = MenuEvent::receiver(); + + // Create the event loop + let event_loop = EventLoopBuilder::new().build(); + + // Store tray icon in Option - created after event loop starts + let mut tray_icon: Option = None; + + // Run the event loop + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Poll; + + match event { + Event::NewEvents(StartCause::Init) => { + // Hide from dock on macOS (must be done after event loop starts) + #[cfg(target_os = "macos")] + { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; + + if let Some(mtm) = MainThreadMarker::new() { + let app = NSApplication::sharedApplication(mtm); + app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + } + } + + // Create tray icon after event loop has started (required for macOS) + tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu)); + log::info!("[daemon] Tray icon created"); + } + Event::MainEventsCleared => { + // Process menu events + while let Ok(event) = menu_channel.try_recv() { + if event.id == tray_menu.open_item.id() || event.id == tray_menu.preferences_item.id() { + tray::open_gui(); + } else if event.id == tray_menu.quit_item.id() { + log::info!("[daemon] Quit requested"); + SHOULD_QUIT.store(true, Ordering::SeqCst); + } + } + + if SHOULD_QUIT.load(Ordering::SeqCst) { + // Cleanup + let mut state = read_state(); + state.daemon_pid = None; + let _ = write_state(&state); + log::info!("[daemon] Exiting"); + *control_flow = ControlFlow::Exit; + } + } + _ => {} + } + + // Keep tray_icon alive + let _ = &tray_icon; + }); +} + +fn stop_daemon() { + let state = read_state(); + + if let Some(pid) = state.daemon_pid { + #[cfg(unix)] + { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + eprintln!("Sent stop signal to daemon (PID {})", pid); + } + + #[cfg(windows)] + { + use std::process::Command; + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output(); + eprintln!("Sent stop signal to daemon (PID {})", pid); + } + } else { + eprintln!("Daemon is not running"); + } +} + +fn show_status() { + let state = read_state(); + + if let Some(pid) = state.daemon_pid { + #[cfg(unix)] + let is_running = unsafe { libc::kill(pid as i32, 0) == 0 }; + + #[cfg(windows)] + let is_running = { + use std::process::Command; + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid)]) + .output(); + output + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .unwrap_or(false) + }; + + #[cfg(not(any(unix, windows)))] + let is_running = false; + + if is_running { + eprintln!("Daemon is running (PID {})", pid); + if let Some(port) = state.api_port { + eprintln!(" API: Running on port {}", port); + } else { + eprintln!(" API: Stopped"); + } + eprintln!( + " MCP: {}", + if state.mcp_running { + "Running" + } else { + "Stopped" + } + ); + } else { + eprintln!("Daemon is not running (stale PID in state file)"); + } + } else { + eprintln!("Daemon is not running"); + } +} + +fn print_usage() { + eprintln!("Donut Browser Daemon"); + eprintln!(); + eprintln!("Usage: donut-daemon "); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" start Start the daemon (detaches from terminal)"); + eprintln!(" stop Stop the running daemon"); + eprintln!(" status Show daemon status"); + eprintln!(" run Run in foreground (for debugging)"); + eprintln!(" autostart Manage autostart settings"); + eprintln!(" enable Enable autostart on login"); + eprintln!(" disable Disable autostart on login"); + eprintln!(" status Show autostart status"); +} + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + print_usage(); + process::exit(1); + } + + match args[1].as_str() { + "start" => { + eprintln!("Starting daemon..."); + spawn_detached(); + run_daemon(); + } + "stop" => { + stop_daemon(); + } + "status" => { + show_status(); + } + "run" => { + run_daemon(); + } + "--daemon-internal" => { + run_daemon(); + } + "autostart" => { + if args.len() < 3 { + eprintln!("Usage: donut-daemon autostart "); + process::exit(1); + } + match args[2].as_str() { + "enable" => { + if let Err(e) = autostart::enable_autostart() { + eprintln!("Failed to enable autostart: {}", e); + process::exit(1); + } + eprintln!("Autostart enabled"); + } + "disable" => { + if let Err(e) = autostart::disable_autostart() { + eprintln!("Failed to disable autostart: {}", e); + process::exit(1); + } + eprintln!("Autostart disabled"); + } + "status" => { + if autostart::is_autostart_enabled() { + eprintln!("Autostart is enabled"); + } else { + eprintln!("Autostart is disabled"); + } + } + _ => { + eprintln!("Unknown autostart command: {}", args[2]); + process::exit(1); + } + } + } + _ => { + print_usage(); + process::exit(1); + } + } +} diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index e5e46ee..660ce1a 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -1,6 +1,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings}; use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager}; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; +use crate::events; use crate::platform_browser; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; @@ -10,7 +11,6 @@ use serde::Serialize; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use sysinfo::System; -use tauri::Emitter; pub struct BrowserRunner { base_dirs: BaseDirs, pub profile_manager: &'static ProfileManager, @@ -261,7 +261,7 @@ impl BrowserRunner { // Emit profiles-changed to trigger frontend to reload profiles from disk // This ensures the UI displays the newly generated fingerprint - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -271,7 +271,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -287,7 +287,7 @@ impl BrowserRunner { is_running: updated_profile.process_id.is_some(), }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -459,7 +459,7 @@ impl BrowserRunner { ); // Emit profiles-changed to trigger frontend to reload profiles from disk - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -469,7 +469,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -485,7 +485,7 @@ impl BrowserRunner { is_running: updated_profile.process_id.is_some(), }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -710,7 +710,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -725,7 +725,7 @@ impl BrowserRunner { is_running: updated_profile.process_id.is_some(), }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -1594,7 +1594,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -1609,7 +1609,7 @@ impl BrowserRunner { is_running: false, // Explicitly set to false since we just killed it }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -1912,7 +1912,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -1927,7 +1927,7 @@ impl BrowserRunner { is_running: false, }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -2181,7 +2181,7 @@ impl BrowserRunner { ); // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -2196,7 +2196,7 @@ impl BrowserRunner { is_running: false, // Explicitly set to false since we just killed it }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( @@ -2502,7 +2502,7 @@ pub async fn launch_browser_profile( is_running: false, }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } @@ -2579,7 +2579,7 @@ pub async fn kill_browser_profile( is_running: true, }; - if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } diff --git a/src-tauri/src/camoufox/config.rs b/src-tauri/src/camoufox/config.rs index 4825e66..5e7aaf4 100644 --- a/src-tauri/src/camoufox/config.rs +++ b/src-tauri/src/camoufox/config.rs @@ -438,7 +438,9 @@ impl CamoufoxConfigBuilder { let ip = match geoip { GeoIPOption::Auto => { // Fetch public IP, optionally through proxy - geolocation::fetch_public_ip(proxy_url.as_deref()).await? + geolocation::fetch_public_ip(proxy_url.as_deref()) + .await + .map_err(geolocation::GeolocationError::from)? } GeoIPOption::IP(ip_str) => { if !geolocation::validate_ip(&ip_str) { diff --git a/src-tauri/src/camoufox/geolocation.rs b/src-tauri/src/camoufox/geolocation.rs index ac56363..5b881ab 100644 --- a/src-tauri/src/camoufox/geolocation.rs +++ b/src-tauri/src/camoufox/geolocation.rs @@ -15,6 +15,9 @@ use std::net::IpAddr; use std::path::PathBuf; use std::str::FromStr; +// Re-export IP utilities for backward compatibility +pub use crate::ip_utils::{fetch_public_ip, is_ipv4, is_ipv6, validate_ip, IpError}; + /// Geolocation error type. #[derive(Debug, thiserror::Error)] pub enum GeolocationError { @@ -41,6 +44,9 @@ pub enum GeolocationError { #[error("Network error: {0}")] Network(String), + + #[error("IP error: {0}")] + Ip(#[from] IpError), } /// Locale information. @@ -379,84 +385,6 @@ pub fn get_geolocation(ip: &str) -> Result { }) } -/// Validate an IP address (IPv4 or IPv6). -pub fn validate_ip(ip: &str) -> bool { - IpAddr::from_str(ip).is_ok() -} - -/// Check if an IP is IPv4. -pub fn is_ipv4(ip: &str) -> bool { - if let Ok(addr) = IpAddr::from_str(ip) { - addr.is_ipv4() - } else { - false - } -} - -/// Check if an IP is IPv6. -pub fn is_ipv6(ip: &str) -> bool { - if let Ok(addr) = IpAddr::from_str(ip) { - addr.is_ipv6() - } else { - false - } -} - -/// Fetch public IP address, optionally through a proxy. -pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { - let urls = [ - "https://api.ipify.org", - "https://checkip.amazonaws.com", - "https://ipinfo.io/ip", - "https://icanhazip.com", - "https://ifconfig.co/ip", - "https://ipecho.net/plain", - ]; - - let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5)); - - let client = if let Some(proxy_url) = proxy { - let proxy = reqwest::Proxy::all(proxy_url) - .map_err(|e| GeolocationError::Network(format!("Invalid proxy: {}", e)))?; - client_builder - .proxy(proxy) - .build() - .map_err(|e| GeolocationError::Network(e.to_string()))? - } else { - client_builder - .build() - .map_err(|e| GeolocationError::Network(e.to_string()))? - }; - - let mut last_error = None; - - for url in &urls { - match client.get(*url).send().await { - Ok(response) if response.status().is_success() => match response.text().await { - Ok(text) => { - let ip = text.trim().to_string(); - if validate_ip(&ip) { - return Ok(ip); - } - } - Err(e) => { - last_error = Some(format!("Failed to read response from {}: {}", url, e)); - } - }, - Ok(response) => { - last_error = Some(format!("HTTP {} from {}", response.status(), url)); - } - Err(e) => { - last_error = Some(format!("Request to {} failed: {}", url, e)); - } - } - } - - Err(GeolocationError::Network(last_error.unwrap_or_else(|| { - "Failed to fetch public IP from any endpoint".to_string() - }))) -} - #[cfg(test)] mod tests { use super::*; @@ -500,29 +428,6 @@ mod tests { assert_eq!(locale_no_region.as_string(), "en"); } - #[test] - fn test_validate_ip() { - assert!(validate_ip("8.8.8.8")); - assert!(validate_ip("192.168.1.1")); - assert!(validate_ip("2001:4860:4860::8888")); - assert!(!validate_ip("invalid")); - assert!(!validate_ip("256.256.256.256")); - } - - #[test] - fn test_is_ipv4() { - assert!(is_ipv4("8.8.8.8")); - assert!(!is_ipv4("2001:4860:4860::8888")); - assert!(!is_ipv4("invalid")); - } - - #[test] - fn test_is_ipv6() { - assert!(is_ipv6("2001:4860:4860::8888")); - assert!(!is_ipv6("8.8.8.8")); - assert!(!is_ipv6("invalid")); - } - #[test] fn test_normalize_locale() { let locale = normalize_locale("en-US"); diff --git a/src-tauri/src/commercial_license.rs b/src-tauri/src/commercial_license.rs index f0ba5ab..f0e08fa 100644 --- a/src-tauri/src/commercial_license.rs +++ b/src-tauri/src/commercial_license.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; +use crate::events; use crate::settings_manager::SettingsManager; const TRIAL_DURATION_SECONDS: u64 = 14 * 24 * 60 * 60; // 2 weeks @@ -60,7 +61,7 @@ impl CommercialLicenseManager { } } - async fn get_or_set_first_launch(&self, app_handle: &AppHandle) -> Result { + async fn get_or_set_first_launch(&self, _app_handle: &AppHandle) -> Result { let settings_manager = SettingsManager::instance(); let mut settings = settings_manager .load_settings() @@ -80,7 +81,7 @@ impl CommercialLicenseManager { log::info!("First launch timestamp recorded: {now}"); // Emit event to notify frontend - if let Err(e) = app_handle.emit("first-launch-recorded", now) { + if let Err(e) = events::emit("first-launch-recorded", now) { log::warn!("Failed to emit first-launch-recorded event: {e}"); } diff --git a/src-tauri/src/daemon/autostart.rs b/src-tauri/src/daemon/autostart.rs new file mode 100644 index 0000000..a15bf06 --- /dev/null +++ b/src-tauri/src/daemon/autostart.rs @@ -0,0 +1,247 @@ +use directories::ProjectDirs; +use std::fs; +use std::io; +use std::path::PathBuf; + +fn get_daemon_path() -> Option { + // First try to find the daemon binary in the same directory as the current executable + if let Ok(current_exe) = std::env::current_exe() { + let daemon_path = current_exe.parent()?.join(daemon_binary_name()); + if daemon_path.exists() { + return Some(daemon_path); + } + } + + // Try common installation paths + #[cfg(target_os = "macos")] + { + let paths = [ + PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"), + dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"), + ]; + for path in paths { + if path.exists() { + return Some(path); + } + } + } + + #[cfg(target_os = "windows")] + { + let paths = [ + dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"), + PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"), + ]; + for path in paths { + if path.exists() { + return Some(path); + } + } + } + + #[cfg(target_os = "linux")] + { + let paths = [ + PathBuf::from("/usr/bin/donut-daemon"), + PathBuf::from("/usr/local/bin/donut-daemon"), + dirs::home_dir()?.join(".local/bin/donut-daemon"), + ]; + for path in paths { + if path.exists() { + return Some(path); + } + } + } + + None +} + +fn daemon_binary_name() -> &'static str { + #[cfg(windows)] + { + "donut-daemon.exe" + } + #[cfg(not(windows))] + { + "donut-daemon" + } +} + +#[cfg(target_os = "macos")] +pub fn enable_autostart() -> io::Result<()> { + let daemon_path = get_daemon_path() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; + + let plist_dir = dirs::home_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))? + .join("Library/LaunchAgents"); + + fs::create_dir_all(&plist_dir)?; + + let plist_path = plist_dir.join("com.donutbrowser.daemon.plist"); + + let plist_content = format!( + r#" + + + + Label + com.donutbrowser.daemon + ProgramArguments + + {} + start + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/donut-daemon.out.log + StandardErrorPath + /tmp/donut-daemon.err.log + + +"#, + daemon_path.display() + ); + + fs::write(&plist_path, plist_content)?; + + log::info!("Created launch agent at {:?}", plist_path); + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn disable_autostart() -> io::Result<()> { + let plist_path = dirs::home_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))? + .join("Library/LaunchAgents/com.donutbrowser.daemon.plist"); + + if plist_path.exists() { + fs::remove_file(&plist_path)?; + log::info!("Removed launch agent at {:?}", plist_path); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn is_autostart_enabled() -> bool { + dirs::home_dir() + .map(|h| { + h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist") + .exists() + }) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +pub fn enable_autostart() -> io::Result<()> { + let daemon_path = get_daemon_path() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; + + let autostart_dir = dirs::config_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))? + .join("autostart"); + + fs::create_dir_all(&autostart_dir)?; + + let desktop_path = autostart_dir.join("donut-daemon.desktop"); + + let desktop_content = format!( + r#"[Desktop Entry] +Type=Application +Name=Donut Browser Daemon +Exec={} start +Hidden=false +NoDisplay=true +X-GNOME-Autostart-enabled=true +"#, + daemon_path.display() + ); + + fs::write(&desktop_path, desktop_content)?; + + log::info!("Created autostart entry at {:?}", desktop_path); + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn disable_autostart() -> io::Result<()> { + let desktop_path = dirs::config_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))? + .join("autostart/donut-daemon.desktop"); + + if desktop_path.exists() { + fs::remove_file(&desktop_path)?; + log::info!("Removed autostart entry at {:?}", desktop_path); + } + + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn is_autostart_enabled() -> bool { + dirs::config_dir() + .map(|c| c.join("autostart/donut-daemon.desktop").exists()) + .unwrap_or(false) +} + +#[cfg(target_os = "windows")] +pub fn enable_autostart() -> io::Result<()> { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let daemon_path = get_daemon_path() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?; + + key.set_value( + "DonutBrowserDaemon", + &format!("\"{}\" start", daemon_path.display()), + )?; + + log::info!("Added registry autostart entry"); + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn disable_autostart() -> io::Result<()> { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + winreg::enums::KEY_WRITE, + ) { + let _ = key.delete_value("DonutBrowserDaemon"); + log::info!("Removed registry autostart entry"); + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn is_autostart_enabled() -> bool { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") { + key.get_value::("DonutBrowserDaemon").is_ok() + } else { + false + } +} + +pub fn get_data_dir() -> Option { + if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") { + Some(proj_dirs.data_dir().to_path_buf()) + } else { + dirs::home_dir().map(|h| h.join(".donutbrowser")) + } +} diff --git a/src-tauri/src/daemon/mod.rs b/src-tauri/src/daemon/mod.rs new file mode 100644 index 0000000..f5280f9 --- /dev/null +++ b/src-tauri/src/daemon/mod.rs @@ -0,0 +1,3 @@ +pub mod autostart; +pub mod services; +pub mod tray; diff --git a/src-tauri/src/daemon/services.rs b/src-tauri/src/daemon/services.rs new file mode 100644 index 0000000..870f823 --- /dev/null +++ b/src-tauri/src/daemon/services.rs @@ -0,0 +1,51 @@ +use crate::events::{self, DaemonEmitter, DaemonEvent}; +use std::sync::Arc; +use tokio::sync::broadcast; + +pub struct DaemonServices { + pub api_port: Option, + pub mcp_running: bool, + event_emitter: Arc, +} + +impl DaemonServices { + pub async fn start() -> Result { + log::info!("Starting daemon services..."); + + // Create the daemon event emitter + let (emitter, _rx) = DaemonEmitter::with_capacity(256); + let emitter_arc = Arc::new(emitter); + + // Set the global event emitter + if let Err(e) = events::set_global_emitter(emitter_arc.clone()) { + log::warn!("Failed to set global event emitter: {}", e); + } + + // NOTE: The API server currently requires an AppHandle which is only available + // in the Tauri GUI context. For now, the daemon starts with minimal services. + // The GUI will start the API server when it connects to the daemon. + // + // TODO: Refactor API server to work without AppHandle for daemon mode + let api_port = None; + let mcp_running = false; + + log::info!("Daemon services started (minimal mode - waiting for GUI connection)"); + + Ok(Self { + api_port, + mcp_running, + event_emitter: emitter_arc, + }) + } + + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.event_emitter.subscribe() + } + + pub async fn stop(&mut self) { + log::info!("Stopping daemon services..."); + + self.api_port = None; + self.mcp_running = false; + } +} diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs new file mode 100644 index 0000000..02a3109 --- /dev/null +++ b/src-tauri/src/daemon/tray.rs @@ -0,0 +1,150 @@ +use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; + +static GUI_RUNNING: AtomicBool = AtomicBool::new(false); + +pub fn load_icon() -> Icon { + let icon_bytes = include_bytes!("../../icons/32x32.png"); + + let image = image::load_from_memory(icon_bytes) + .expect("Failed to load icon") + .into_rgba8(); + + let (width, height) = image.dimensions(); + let rgba = image.into_raw(); + + Icon::from_rgba(rgba, width, height).expect("Failed to create icon") +} + +pub struct TrayMenu { + pub menu: Menu, + pub open_item: MenuItem, + pub running_profiles_submenu: Submenu, + pub api_status_item: MenuItem, + pub mcp_status_item: MenuItem, + pub preferences_item: MenuItem, + pub quit_item: MenuItem, +} + +impl Default for TrayMenu { + fn default() -> Self { + Self::new() + } +} + +impl TrayMenu { + pub fn new() -> Self { + let menu = Menu::new(); + + let open_item = MenuItem::new("Open Donut Browser", true, None); + let running_profiles_submenu = Submenu::new("Running Profiles", true); + let no_profiles_item = MenuItem::new("No running profiles", false, None); + running_profiles_submenu.append(&no_profiles_item).unwrap(); + + let separator1 = PredefinedMenuItem::separator(); + let api_status_item = MenuItem::new("API: Starting...", false, None); + let mcp_status_item = MenuItem::new("MCP: Starting...", false, None); + let separator2 = PredefinedMenuItem::separator(); + let preferences_item = MenuItem::new("Preferences...", true, None); + let quit_item = MenuItem::new("Quit Donut Browser", true, None); + + menu.append(&open_item).unwrap(); + menu.append(&running_profiles_submenu).unwrap(); + menu.append(&separator1).unwrap(); + menu.append(&api_status_item).unwrap(); + menu.append(&mcp_status_item).unwrap(); + menu.append(&separator2).unwrap(); + menu.append(&preferences_item).unwrap(); + menu.append(&quit_item).unwrap(); + + Self { + menu, + open_item, + running_profiles_submenu, + api_status_item, + mcp_status_item, + preferences_item, + quit_item, + } + } + + pub fn update_api_status(&self, port: Option) { + let text = match port { + Some(p) => format!("API: Running on :{}", p), + None => "API: Stopped".to_string(), + }; + self.api_status_item.set_text(&text); + } + + pub fn update_mcp_status(&self, running: bool) { + let text = if running { + "MCP: Running" + } else { + "MCP: Stopped" + }; + self.mcp_status_item.set_text(text); + } +} + +pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon { + TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip("Donut Browser") + .with_menu(Box::new(menu.clone())) + .build() + .expect("Failed to create tray icon") +} + +pub fn open_gui() { + if GUI_RUNNING.load(Ordering::SeqCst) { + log::info!("GUI already running, activating..."); + activate_gui(); + return; + } + + log::info!("Opening GUI..."); + + #[cfg(target_os = "macos")] + { + let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn(); + } + + #[cfg(target_os = "windows")] + { + use std::path::PathBuf; + + let paths = [ + dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")), + Some(PathBuf::from( + "C:\\Program Files\\Donut Browser\\Donut Browser.exe", + )), + ]; + + for path in paths.iter().flatten() { + if path.exists() { + let _ = Command::new(path).spawn(); + return; + } + } + } + + #[cfg(target_os = "linux")] + { + let _ = Command::new("donutbrowser").spawn(); + } +} + +pub fn activate_gui() { + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .args(["-e", "tell application \"Donut Browser\" to activate"]) + .spawn(); + } +} + +pub fn set_gui_running(running: bool) { + GUI_RUNNING.store(running, Ordering::SeqCst); +} diff --git a/src-tauri/src/daemon_client.rs b/src-tauri/src/daemon_client.rs new file mode 100644 index 0000000..de786da --- /dev/null +++ b/src-tauri/src/daemon_client.rs @@ -0,0 +1,152 @@ +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tauri::Emitter; +use tokio::sync::Mutex; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsMessage { + #[serde(rename = "type")] + pub msg_type: String, + pub event: Option, + pub payload: Option, +} + +pub struct DaemonClient { + app_handle: tauri::AppHandle, + connected: Arc, + shutdown: Arc, + daemon_port: Arc>>, +} + +impl DaemonClient { + pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { + app_handle, + connected: Arc::new(AtomicBool::new(false)), + shutdown: Arc::new(AtomicBool::new(false)), + daemon_port: Arc::new(Mutex::new(None)), + } + } + + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::SeqCst) + } + + pub async fn connect(&self, port: u16) -> Result<(), String> { + *self.daemon_port.lock().await = Some(port); + + let url = format!("ws://127.0.0.1:{}/ws/events", port); + + log::info!("[daemon-client] Connecting to daemon at {}", url); + + let (ws_stream, _) = connect_async(&url) + .await + .map_err(|e| format!("Failed to connect to daemon: {}", e))?; + + self.connected.store(true, Ordering::SeqCst); + log::info!("[daemon-client] Connected to daemon"); + + let (mut write, mut read) = ws_stream.split(); + + let app_handle = self.app_handle.clone(); + let connected = self.connected.clone(); + let shutdown = self.shutdown.clone(); + + // Spawn task to handle incoming messages + tokio::spawn(async move { + while !shutdown.load(Ordering::SeqCst) { + match read.next().await { + Some(Ok(Message::Text(text))) => { + if let Ok(ws_msg) = serde_json::from_str::(&text) { + match ws_msg.msg_type.as_str() { + "event" => { + if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) { + // Forward event to Tauri frontend + if let Err(e) = app_handle.emit(&event, payload) { + log::error!("[daemon-client] Failed to emit event: {}", e); + } + } + } + "connected" => { + log::info!("[daemon-client] Received connection confirmation"); + } + "pong" => { + log::debug!("[daemon-client] Received pong"); + } + _ => { + log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type); + } + } + } + } + Some(Ok(Message::Ping(data))) => { + log::debug!("[daemon-client] Received ping"); + if let Err(e) = write.send(Message::Pong(data)).await { + log::error!("[daemon-client] Failed to send pong: {}", e); + break; + } + } + Some(Ok(Message::Close(_))) => { + log::info!("[daemon-client] Daemon closed connection"); + break; + } + Some(Err(e)) => { + log::error!("[daemon-client] WebSocket error: {}", e); + break; + } + None => { + log::info!("[daemon-client] WebSocket stream ended"); + break; + } + _ => {} + } + } + + connected.store(false, Ordering::SeqCst); + log::info!("[daemon-client] Disconnected from daemon"); + }); + + Ok(()) + } + + pub fn disconnect(&self) { + self.shutdown.store(true, Ordering::SeqCst); + self.connected.store(false, Ordering::SeqCst); + } +} + +pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient { + let client = DaemonClient::new(app_handle); + + if let Err(e) = client.connect(port).await { + log::error!("[daemon-client] Failed to connect: {}", e); + } + + client +} + +pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option { + // Try default port first + let default_port = 10108; + + log::info!( + "[daemon-client] Looking for daemon on port {}", + default_port + ); + + let client = DaemonClient::new(app_handle); + + match client.connect(default_port).await { + Ok(()) => Some(client), + Err(e) => { + log::warn!( + "[daemon-client] Could not connect to daemon on default port: {}", + e + ); + None + } + } +} diff --git a/src-tauri/src/daemon_ws.rs b/src-tauri/src/daemon_ws.rs new file mode 100644 index 0000000..ed0052c --- /dev/null +++ b/src-tauri/src/daemon_ws.rs @@ -0,0 +1,134 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::events::{DaemonEmitter, DaemonEvent}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsMessage { + #[serde(rename = "type")] + pub msg_type: String, + pub event: Option, + pub payload: Option, +} + +#[derive(Clone)] +pub struct WsState { + event_emitter: Option>, +} + +impl WsState { + pub fn new() -> Self { + Self { + event_emitter: None, + } + } + + pub fn with_emitter(emitter: Arc) -> Self { + Self { + event_emitter: Some(emitter), + } + } +} + +impl Default for WsState { + fn default() -> Self { + Self::new() + } +} + +pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: WsState) { + let (mut sender, mut receiver) = socket.split(); + + // Subscribe to daemon events if emitter is available + let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe()); + + log::info!("[ws] Client connected"); + + // Send initial ping to confirm connection + let ping_msg = WsMessage { + msg_type: "connected".to_string(), + event: None, + payload: None, + }; + if let Ok(msg_str) = serde_json::to_string(&ping_msg) { + let _ = sender.send(Message::Text(msg_str.into())).await; + } + + loop { + tokio::select! { + // Handle incoming messages from client + Some(msg) = receiver.next() => { + match msg { + Ok(Message::Text(text)) => { + if let Ok(ws_msg) = serde_json::from_str::(&text) { + match ws_msg.msg_type.as_str() { + "ping" => { + let pong = WsMessage { + msg_type: "pong".to_string(), + event: None, + payload: None, + }; + if let Ok(msg_str) = serde_json::to_string(&pong) { + let _ = sender.send(Message::Text(msg_str.into())).await; + } + } + _ => { + log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type); + } + } + } + } + Ok(Message::Ping(data)) => { + let _ = sender.send(Message::Pong(data)).await; + } + Ok(Message::Close(_)) => { + log::info!("[ws] Client disconnected"); + break; + } + Err(e) => { + log::error!("[ws] Error receiving message: {}", e); + break; + } + _ => {} + } + } + + // Forward daemon events to client + Some(daemon_event) = async { + if let Some(ref mut rx) = event_rx { + rx.recv().await.ok() + } else { + std::future::pending::>().await + } + } => { + let ws_msg = WsMessage { + msg_type: "event".to_string(), + event: Some(daemon_event.event_type), + payload: Some(daemon_event.payload), + }; + if let Ok(msg_str) = serde_json::to_string(&ws_msg) { + if sender.send(Message::Text(msg_str.into())).await.is_err() { + log::error!("[ws] Failed to send event to client"); + break; + } + } + } + + else => break, + } + } + + log::info!("[ws] WebSocket connection closed"); +} diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 62057d7..4e761f0 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -3,11 +3,11 @@ use serde::{Deserialize, Serialize}; use std::io; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use tauri::Emitter; use crate::api_client::ApiClient; use crate::browser::{create_browser, BrowserType}; use crate::browser_version_manager::DownloadInfo; +use crate::events; // Global state to track currently downloading browser-version pairs lazy_static::lazy_static! { @@ -433,7 +433,7 @@ impl Downloader { pub async fn download_browser( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, browser_type: BrowserType, version: &str, download_info: &DownloadInfo, @@ -561,7 +561,7 @@ impl Downloader { stage: initial_stage, }; - let _ = app_handle.emit("download-progress", &progress); + let _ = events::emit("download-progress", &progress); // Open file in append mode (resuming) or create new use std::fs::OpenOptions; @@ -620,7 +620,7 @@ impl Downloader { stage: stage_description, }; - let _ = app_handle.emit("download-progress", &progress); + let _ = events::emit("download-progress", &progress); last_update = now; } } @@ -801,7 +801,7 @@ impl Downloader { eta_seconds: None, stage: "verifying".to_string(), }; - let _ = app_handle.emit("download-progress", &progress); + let _ = events::emit("download-progress", &progress); // Verify the browser was downloaded correctly log::info!("Verifying download for browser: {browser_str}, version: {version}"); @@ -939,7 +939,7 @@ impl Downloader { eta_seconds: Some(0.0), stage: "completed".to_string(), }; - let _ = app_handle.emit("download-progress", &progress); + let _ = events::emit("download-progress", &progress); // Remove browser-version pair from downloading set { diff --git a/src-tauri/src/events/mod.rs b/src-tauri/src/events/mod.rs new file mode 100644 index 0000000..159f494 --- /dev/null +++ b/src-tauri/src/events/mod.rs @@ -0,0 +1,171 @@ +use serde::Serialize; +use std::sync::Arc; +use tokio::sync::broadcast; + +/// Trait for emitting events to the frontend or connected clients. +/// This abstraction allows the same code to work in both GUI (Tauri) mode +/// and daemon mode (WebSocket broadcast). +/// +/// Note: This trait uses `serde_json::Value` to be dyn-compatible. +/// Use the convenience functions `emit()` and `emit_empty()` which accept +/// any Serialize type. +pub trait EventEmitter: Send + Sync { + /// Emit an event with a JSON value payload. + fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String>; +} + +/// Tauri-based event emitter for GUI mode. +/// Wraps an AppHandle and emits events directly to the Tauri frontend. +#[derive(Clone)] +pub struct TauriEmitter { + app_handle: tauri::AppHandle, +} + +impl TauriEmitter { + pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { app_handle } + } +} + +impl EventEmitter for TauriEmitter { + fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { + use tauri::Emitter; + self + .app_handle + .emit(event, payload) + .map_err(|e| e.to_string()) + } +} + +/// Event message sent through the daemon's broadcast channel. +#[derive(Clone, Debug)] +pub struct DaemonEvent { + pub event_type: String, + pub payload: serde_json::Value, +} + +/// Daemon-based event emitter for background daemon mode. +/// Broadcasts events to all connected WebSocket clients. +#[derive(Clone)] +pub struct DaemonEmitter { + tx: broadcast::Sender, +} + +impl DaemonEmitter { + pub fn new(tx: broadcast::Sender) -> Self { + Self { tx } + } + + /// Create a new DaemonEmitter with a default channel capacity. + pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver) { + let (tx, rx) = broadcast::channel(capacity); + (Self { tx }, rx) + } + + /// Subscribe to events from this emitter. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +impl EventEmitter for DaemonEmitter { + fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { + let daemon_event = DaemonEvent { + event_type: event.to_string(), + payload, + }; + // Ignore send errors (no receivers connected) + let _ = self.tx.send(daemon_event); + Ok(()) + } +} + +/// No-op emitter for testing or when events are not needed. +#[derive(Clone, Default)] +pub struct NoopEmitter; + +impl EventEmitter for NoopEmitter { + fn emit_value(&self, _event: &str, _payload: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} + +/// Global event emitter that can be set at runtime. +/// This allows managers to emit events without knowing whether they're +/// running in GUI or daemon mode. +static GLOBAL_EMITTER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Set the global event emitter. This should be called once during app startup. +/// Returns an error if the emitter has already been set. +pub fn set_global_emitter(emitter: Arc) -> Result<(), String> { + GLOBAL_EMITTER + .set(emitter) + .map_err(|_| "Global emitter already set".to_string()) +} + +/// Get the global event emitter, or a no-op emitter if none has been set. +pub fn global_emitter() -> Arc { + GLOBAL_EMITTER + .get() + .cloned() + .unwrap_or_else(|| Arc::new(NoopEmitter)) +} + +/// Emit an event using the global emitter. +/// This is a convenience function for use in managers. +/// Accepts any type that implements Serialize. +pub fn emit(event: &str, payload: S) -> Result<(), String> { + let value = serde_json::to_value(payload).map_err(|e| e.to_string())?; + global_emitter().emit_value(event, value) +} + +/// Emit an event with no payload using the global emitter. +pub fn emit_empty(event: &str) -> Result<(), String> { + global_emitter().emit_value(event, serde_json::Value::Null) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_noop_emitter() { + let emitter = NoopEmitter; + assert!(emitter + .emit_value("test-event", serde_json::json!("payload")) + .is_ok()); + } + + #[test] + fn test_daemon_emitter() { + let (emitter, mut rx) = DaemonEmitter::with_capacity(16); + + // Emit an event + let _ = emitter.emit_value("test-event", serde_json::json!("hello")); + + // Check we received it + let event = rx.try_recv().unwrap(); + assert_eq!(event.event_type, "test-event"); + assert_eq!(event.payload, serde_json::json!("hello")); + } + + #[test] + fn test_daemon_emitter_no_receivers() { + let (tx, _) = broadcast::channel::(16); + let emitter = DaemonEmitter::new(tx); + + // Should not error even with no receivers + assert!(emitter + .emit_value("test-event", serde_json::json!("hello")) + .is_ok()); + } + + #[test] + fn test_emit_convenience_function() { + // Test that emit() works with various types + assert!(emit("test", "string").is_ok()); + assert!(emit("test", 42).is_ok()); + assert!(emit("test", serde_json::json!({"key": "value"})).is_ok()); + assert!(emit_empty("test").is_ok()); + } +} diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index cbee56f..cb1a11a 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -1,10 +1,10 @@ use std::fs::{self, File}; use std::io::{self, BufReader, Read}; use std::path::{Path, PathBuf}; -use tauri::Emitter; use crate::browser::BrowserType; use crate::downloader::DownloadProgress; +use crate::events; #[cfg(any(target_os = "macos", target_os = "windows"))] use std::process::Command; @@ -100,7 +100,7 @@ impl Extractor { pub async fn extract_browser( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, browser_type: BrowserType, version: &str, archive_path: &Path, @@ -117,7 +117,7 @@ impl Extractor { eta_seconds: None, stage: "extracting".to_string(), }; - let _ = app_handle.emit("download-progress", &progress); + let _ = events::emit("download-progress", &progress); log::info!( "Starting extraction of {} for browser {} version {}", diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 377c001..28e1963 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -1,10 +1,10 @@ use crate::browser::GithubRelease; +use crate::events; use crate::profile::manager::ProfileManager; use directories::BaseDirs; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use tauri::Emitter; use tokio::fs; use tokio::io::AsyncWriteExt; @@ -107,10 +107,10 @@ impl GeoIPDownloader { pub async fn download_geoip_database( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { // Emit initial progress - let _ = app_handle.emit( + let _ = events::emit( "geoip-download-progress", GeoIPDownloadProgress { stage: "downloading".to_string(), @@ -183,7 +183,7 @@ impl GeoIPDownloader { None }; - let _ = app_handle.emit( + let _ = events::emit( "geoip-download-progress", GeoIPDownloadProgress { stage: "downloading".to_string(), @@ -202,7 +202,7 @@ impl GeoIPDownloader { file.flush().await?; // Emit completion - let _ = app_handle.emit( + let _ = events::emit( "geoip-download-progress", GeoIPDownloadProgress { stage: "completed".to_string(), diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index 1224a6d..b475a75 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -4,7 +4,8 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use tauri::Emitter; + +use crate::events; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileGroup { @@ -108,7 +109,7 @@ impl GroupManager { pub fn create_group( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, name: String, ) -> Result> { let mut groups_data = self.load_groups_data()?; @@ -129,7 +130,7 @@ impl GroupManager { self.save_groups_data(&groups_data)?; // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("groups-changed", ()) { + if let Err(e) = events::emit_empty("groups-changed") { log::error!("Failed to emit groups-changed event: {e}"); } @@ -138,7 +139,7 @@ impl GroupManager { pub fn update_group( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, id: String, name: String, ) -> Result> { @@ -165,7 +166,7 @@ impl GroupManager { self.save_groups_data(&groups_data)?; // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("groups-changed", ()) { + if let Err(e) = events::emit_empty("groups-changed") { log::error!("Failed to emit groups-changed event: {e}"); } @@ -251,7 +252,7 @@ impl GroupManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("groups-changed", ()) { + if let Err(e) = events::emit_empty("groups-changed") { log::error!("Failed to emit groups-changed event: {e}"); } diff --git a/src-tauri/src/ip_utils.rs b/src-tauri/src/ip_utils.rs new file mode 100644 index 0000000..864de89 --- /dev/null +++ b/src-tauri/src/ip_utils.rs @@ -0,0 +1,122 @@ +//! IP address utilities shared across the application. +//! +//! Provides IP validation and public IP fetching functionality. + +use std::net::IpAddr; +use std::str::FromStr; + +/// IP utility error type. +#[derive(Debug, thiserror::Error)] +pub enum IpError { + #[error("Network error: {0}")] + Network(String), + + #[error("Invalid IP address: {0}")] + InvalidIP(String), +} + +/// Validate an IP address (IPv4 or IPv6). +pub fn validate_ip(ip: &str) -> bool { + IpAddr::from_str(ip).is_ok() +} + +/// Check if an IP is IPv4. +pub fn is_ipv4(ip: &str) -> bool { + if let Ok(addr) = IpAddr::from_str(ip) { + addr.is_ipv4() + } else { + false + } +} + +/// Check if an IP is IPv6. +pub fn is_ipv6(ip: &str) -> bool { + if let Ok(addr) = IpAddr::from_str(ip) { + addr.is_ipv6() + } else { + false + } +} + +/// Fetch public IP address, optionally through a proxy. +pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { + let urls = [ + "https://api.ipify.org", + "https://checkip.amazonaws.com", + "https://ipinfo.io/ip", + "https://icanhazip.com", + "https://ifconfig.co/ip", + "https://ipecho.net/plain", + ]; + + let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5)); + + let client = if let Some(proxy_url) = proxy { + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?; + client_builder + .proxy(proxy) + .build() + .map_err(|e| IpError::Network(e.to_string()))? + } else { + client_builder + .build() + .map_err(|e| IpError::Network(e.to_string()))? + }; + + let mut last_error = None; + + for url in &urls { + match client.get(*url).send().await { + Ok(response) if response.status().is_success() => match response.text().await { + Ok(text) => { + let ip = text.trim().to_string(); + if validate_ip(&ip) { + return Ok(ip); + } + } + Err(e) => { + last_error = Some(format!("Failed to read response from {}: {}", url, e)); + } + }, + Ok(response) => { + last_error = Some(format!("HTTP {} from {}", response.status(), url)); + } + Err(e) => { + last_error = Some(format!("Request to {} failed: {}", url, e)); + } + } + } + + Err(IpError::Network(last_error.unwrap_or_else(|| { + "Failed to fetch public IP from any endpoint".to_string() + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_ip() { + assert!(validate_ip("8.8.8.8")); + assert!(validate_ip("192.168.1.1")); + assert!(validate_ip("2001:4860:4860::8888")); + assert!(!validate_ip("invalid")); + assert!(!validate_ip("256.256.256.256")); + } + + #[test] + fn test_is_ipv4() { + assert!(is_ipv4("8.8.8.8")); + assert!(!is_ipv4("2001:4860:4860::8888")); + assert!(!is_ipv4("invalid")); + } + + #[test] + fn test_is_ipv6() { + assert!(is_ipv6("2001:4860:4860::8888")); + assert!(!is_ipv6("8.8.8.8")); + assert!(!is_ipv6("invalid")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8636b32..942876c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use std::env; use std::sync::Mutex; -use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_log::{Target, TargetKind}; @@ -23,6 +23,7 @@ mod downloader; mod extraction; mod geoip_downloader; mod group_manager; +mod ip_utils; mod platform_browser; mod profile; mod profile_importer; @@ -38,6 +39,10 @@ mod wayfern_terms; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme mod commercial_license; mod cookie_manager; +pub mod daemon; +pub mod daemon_client; +pub mod daemon_ws; +pub mod events; mod mcp_server; mod tag_manager; mod version_updater; @@ -162,8 +167,7 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin let _ = window.set_focus(); let _ = window.unminimize(); - app - .emit("show-profile-selector", url.clone()) + events::emit("show-profile-selector", url.clone()) .map_err(|e| format!("Failed to emit URL open event: {e}"))?; } else { // Window doesn't exist yet - add to pending URLs @@ -272,7 +276,7 @@ fn has_acknowledged_trial_expiration(app_handle: tauri::AppHandle) -> Result Result<(), String> { +async fn start_mcp_server(app_handle: tauri::AppHandle) -> Result { mcp_server::McpServer::instance().start(app_handle).await } @@ -286,6 +290,50 @@ fn get_mcp_server_status() -> bool { mcp_server::McpServer::instance().is_running() } +#[derive(serde::Serialize)] +struct McpConfig { + port: u16, + token: String, + config_json: String, +} + +#[tauri::command] +async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result, String> { + let mcp_server = mcp_server::McpServer::instance(); + if !mcp_server.is_running() { + return Ok(None); + } + + let port = mcp_server + .get_port() + .ok_or("MCP server port not available")?; + + let settings_manager = settings_manager::SettingsManager::instance(); + let token = settings_manager + .get_mcp_token(&app_handle) + .await + .map_err(|e| format!("Failed to get MCP token: {e}"))? + .ok_or("MCP token not found")?; + + let config_json = serde_json::json!({ + "mcpServers": { + "donut-browser": { + "url": format!("http://127.0.0.1:{}/mcp", port), + "headers": { + "Authorization": format!("Bearer {}", token) + } + } + } + }) + .to_string(); + + Ok(Some(McpConfig { + port, + token, + config_json, + })) +} + #[tauri::command] async fn is_geoip_database_available() -> Result { Ok(GeoIPDownloader::is_geoip_database_available()) @@ -405,6 +453,12 @@ pub fn run() { // Set up deep link handler let handle = app.handle().clone(); + // Initialize the global event emitter for the events module + let emitter = std::sync::Arc::new(events::TauriEmitter::new(handle.clone())); + if let Err(e) = events::set_global_emitter(emitter) { + log::warn!("Failed to set global event emitter: {e}"); + } + #[cfg(windows)] { // For Windows, register all deep links at runtime @@ -536,7 +590,7 @@ pub fn run() { } }); - let app_handle_update = app.handle().clone(); + let _app_handle_update = app.handle().clone(); tauri::async_runtime::spawn(async move { log::info!("Starting app update check at startup..."); let updater = app_auto_updater::AppAutoUpdater::instance(); @@ -548,7 +602,7 @@ pub fn run() { update_info.new_version ); // Emit update available event to the frontend - if let Err(e) = app_handle_update.emit("app-update-available", &update_info) { + if let Err(e) = events::emit("app-update-available", &update_info) { log::error!("Failed to emit app update event: {e}"); } else { log::debug!("App update event emitted successfully"); @@ -695,7 +749,7 @@ pub fn run() { is_running, }; - if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) { + if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Failed to emit profile running changed event: {e}"); } else { log::debug!( @@ -735,7 +789,7 @@ pub fn run() { Ok(port) => { log::info!("API server started successfully on port {port}"); // Emit success toast to frontend - if let Err(e) = app_handle_api.emit( + if let Err(e) = events::emit( "show-toast", crate::api_server::ToastPayload { message: "API server started successfully".to_string(), @@ -750,7 +804,7 @@ pub fn run() { Err(e) => { log::error!("Failed to start API server at startup: {e}"); // Emit error toast to frontend - if let Err(toast_err) = app_handle_api.emit( + if let Err(toast_err) = events::emit( "show-toast", crate::api_server::ToastPayload { message: "Failed to start API server".to_string(), @@ -900,7 +954,8 @@ pub fn run() { has_acknowledged_trial_expiration, start_mcp_server, stop_mcp_server, - get_mcp_server_status + get_mcp_server_status, + get_mcp_config ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 3b9379a..ccf8d6d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,15 +1,27 @@ #![allow(dead_code)] +use axum::{ + body::Body, + extract::State, + http::{header, Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::Arc; use tauri::AppHandle; +use tokio::net::TcpListener; use tokio::sync::Mutex as AsyncMutex; use crate::browser::ProxySettings; use crate::group_manager::GROUP_MANAGER; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; +use crate::settings_manager::SettingsManager; use crate::wayfern_terms::WayfernTermsManager; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -44,20 +56,36 @@ pub struct McpError { message: String, } +const DEFAULT_MCP_PORT: u16 = 51080; + struct McpServerInner { app_handle: Option, + token: Option, + shutdown_tx: Option>, +} + +#[derive(Clone)] +struct McpHttpState { + server: &'static McpServer, + token: String, } pub struct McpServer { inner: Arc>, is_running: AtomicBool, + port: AtomicU16, } impl McpServer { fn new() -> Self { Self { - inner: Arc::new(AsyncMutex::new(McpServerInner { app_handle: None })), + inner: Arc::new(AsyncMutex::new(McpServerInner { + app_handle: None, + token: None, + shutdown_tx: None, + })), is_running: AtomicBool::new(false), + port: AtomicU16::new(0), } } @@ -69,8 +97,16 @@ impl McpServer { self.is_running.load(Ordering::SeqCst) } - pub async fn start(&self, app_handle: AppHandle) -> Result<(), String> { - // Check terms acceptance first + pub fn get_port(&self) -> Option { + let port = self.port.load(Ordering::SeqCst); + if port > 0 { + Some(port) + } else { + None + } + } + + pub async fn start(&self, app_handle: AppHandle) -> Result { if !WayfernTermsManager::instance().is_terms_accepted() { return Err( "Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(), @@ -81,12 +117,143 @@ impl McpServer { return Err("MCP server is already running".to_string()); } + let settings_manager = SettingsManager::instance(); + let settings = settings_manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + + // Get or generate token + let existing_token = settings_manager + .get_mcp_token(&app_handle) + .await + .ok() + .flatten(); + + let token = if let Some(t) = existing_token { + t + } else { + settings_manager + .generate_mcp_token(&app_handle) + .await + .map_err(|e| format!("Failed to generate MCP token: {e}"))? + }; + + // Determine port (use saved port, or try default, or random) + let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT); + let actual_port = self.bind_to_available_port(preferred_port).await?; + + // Save port if it changed + if settings.mcp_port != Some(actual_port) { + let mut new_settings = settings; + new_settings.mcp_port = Some(actual_port); + settings_manager + .save_settings(&new_settings) + .map_err(|e| format!("Failed to save settings: {e}"))?; + } + + // Store state let mut inner = self.inner.lock().await; inner.app_handle = Some(app_handle); + inner.token = Some(token.clone()); + + // Create shutdown channel + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + inner.shutdown_tx = Some(shutdown_tx); + + self.port.store(actual_port, Ordering::SeqCst); self.is_running.store(true, Ordering::SeqCst); - log::info!("MCP server started"); - Ok(()) + // Start HTTP server in background + let http_state = McpHttpState { + server: McpServer::instance(), + token, + }; + tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx)); + + log::info!("[mcp] Server started on port {}", actual_port); + Ok(actual_port) + } + + async fn bind_to_available_port(&self, preferred: u16) -> Result { + let addr = SocketAddr::from(([127, 0, 0, 1], preferred)); + if TcpListener::bind(addr).await.is_ok() { + return Ok(preferred); + } + + // Try random ports in 51000-51999 range + for _ in 0..10 { + let port = 51000 + (rand::random::() % 1000); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + if TcpListener::bind(addr).await.is_ok() { + return Ok(port); + } + } + + Err("Could not find available port for MCP server".to_string()) + } + + async fn run_http_server( + port: u16, + state: McpHttpState, + shutdown_rx: tokio::sync::oneshot::Receiver<()>, + ) { + let app = Router::new() + .route("/mcp", post(Self::handle_mcp_post)) + .layer(middleware::from_fn_with_state( + state.clone(), + Self::auth_middleware, + )) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("[mcp] Failed to bind to port {}: {}", port, e); + return; + } + }; + + log::info!( + "[mcp] HTTP server listening on http://127.0.0.1:{}/mcp", + port + ); + + let server = axum::serve(listener, app).with_graceful_shutdown(async { + let _ = shutdown_rx.await; + log::info!("[mcp] HTTP server shutting down"); + }); + + if let Err(e) = server.await { + log::error!("[mcp] HTTP server error: {}", e); + } + } + + async fn auth_middleware( + State(state): State, + req: Request, + next: Next, + ) -> Result { + let auth_header = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + let token = auth_header.and_then(|h| h.strip_prefix("Bearer ")); + + if token != Some(&state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(next.run(req).await) + } + + async fn handle_mcp_post( + State(state): State, + Json(request): Json, + ) -> impl IntoResponse { + let response = state.server.handle_request(request).await; + Json(response) } pub async fn stop(&self) -> Result<(), String> { @@ -96,9 +263,17 @@ impl McpServer { let mut inner = self.inner.lock().await; inner.app_handle = None; + inner.token = None; + + // Send shutdown signal + if let Some(tx) = inner.shutdown_tx.take() { + let _ = tx.send(()); + } + + self.port.store(0, Ordering::SeqCst); self.is_running.store(false, Ordering::SeqCst); - log::info!("MCP server stopped"); + log::info!("[mcp] Server stopped"); Ok(()) } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 1cd794a..db1e450 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -2,6 +2,7 @@ use crate::api_client::is_browser_version_nightly; use crate::browser::{create_browser, BrowserType, ProxySettings}; use crate::camoufox_manager::CamoufoxConfig; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; +use crate::events; use crate::profile::types::BrowserProfile; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::WayfernConfig; @@ -9,7 +10,6 @@ use directories::BaseDirs; use std::fs::{self, create_dir_all}; use std::path::{Path, PathBuf}; use sysinfo::{Pid, System}; -use tauri::Emitter; pub struct ProfileManager { base_dirs: BaseDirs, @@ -357,7 +357,7 @@ impl ProfileManager { } // Emit profile creation event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -410,7 +410,7 @@ impl ProfileManager { pub fn rename_profile( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, new_name: &str, ) -> Result> { @@ -443,7 +443,7 @@ impl ProfileManager { }); // Emit profile rename event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -532,7 +532,7 @@ impl ProfileManager { } // Emit profile deletion event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -541,7 +541,7 @@ impl ProfileManager { pub fn update_profile_version( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, version: &str, ) -> Result> { @@ -585,7 +585,7 @@ impl ProfileManager { self.save_profile(&profile)?; // Emit profile update event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -641,7 +641,7 @@ impl ProfileManager { }); // Emit profile group assignment event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -650,7 +650,7 @@ impl ProfileManager { pub fn update_profile_tags( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, tags: Vec, ) -> Result> { @@ -681,7 +681,7 @@ impl ProfileManager { }); // Emit profile tags update event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -690,7 +690,7 @@ impl ProfileManager { pub fn update_profile_note( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, note: Option, ) -> Result> { @@ -710,7 +710,7 @@ impl ProfileManager { self.save_profile(&profile)?; // Emit profile note update event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -773,7 +773,7 @@ impl ProfileManager { } // Emit profile deletion event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -833,7 +833,7 @@ impl ProfileManager { ); // Emit profile config update event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -893,7 +893,7 @@ impl ProfileManager { ); // Emit profile config update event - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -981,12 +981,12 @@ impl ProfileManager { } // Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager) - if let Err(e) = app_handle.emit("profile-updated", &profile) { + if let Err(e) = events::emit("profile-updated", &profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } // Emit general profiles changed event for profile list updates - if let Err(e) = app_handle.emit("profiles-changed", ()) { + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -1154,7 +1154,7 @@ impl ProfileManager { } // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &merged) { + if let Err(e) = events::emit("profile-updated", &merged) { log::warn!("Warning: Failed to emit profile update event: {e}"); } } @@ -1165,7 +1165,7 @@ impl ProfileManager { // Check Camoufox status using CamoufoxManager async fn check_camoufox_status( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile: &BrowserProfile, ) -> Result> { let launcher = self.camoufox_manager; @@ -1199,7 +1199,7 @@ impl ProfileManager { } // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &latest) { + if let Err(e) = events::emit("profile-updated", &latest) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -1234,7 +1234,7 @@ impl ProfileManager { log::warn!("Warning: Failed to clear Camoufox profile process info: {e}"); } - if let Err(e) = app_handle.emit("profile-updated", &latest) { + if let Err(e) = events::emit("profile-updated", &latest) { log::warn!("Warning: Failed to emit profile update event: {e}"); } } @@ -1267,7 +1267,7 @@ impl ProfileManager { } // Emit profile update event to frontend - if let Err(e3) = app_handle.emit("profile-updated", &latest) { + if let Err(e3) = events::emit("profile-updated", &latest) { log::warn!("Warning: Failed to emit profile update event: {e3}"); } } @@ -1280,7 +1280,7 @@ impl ProfileManager { // Check Wayfern status using WayfernManager async fn check_wayfern_status( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile: &BrowserProfile, ) -> Result> { let manager = self.wayfern_manager; @@ -1314,7 +1314,7 @@ impl ProfileManager { } // Emit profile update event to frontend - if let Err(e) = app_handle.emit("profile-updated", &latest) { + if let Err(e) = events::emit("profile-updated", &latest) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -1349,7 +1349,7 @@ impl ProfileManager { log::warn!("Warning: Failed to clear Wayfern profile process info: {e}"); } - if let Err(e) = app_handle.emit("profile-updated", &latest) { + if let Err(e) = events::emit("profile-updated", &latest) { log::warn!("Warning: Failed to emit profile update event: {e}"); } } diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 1d9f5d2..25c766b 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -6,10 +6,11 @@ use std::fs; use std::path::PathBuf; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::Emitter; use tauri_plugin_shell::ShellExt; use crate::browser::ProxySettings; +use crate::events; +use crate::ip_utils; // Store active proxy information #[derive(Debug, Clone, Serialize, Deserialize)] @@ -308,7 +309,7 @@ impl ProxyManager { // Create a new stored proxy pub fn create_stored_proxy( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, name: String, proxy_settings: ProxySettings, ) -> Result { @@ -332,7 +333,7 @@ impl ProxyManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } @@ -353,7 +354,7 @@ impl ProxyManager { // Update a stored proxy pub fn update_stored_proxy( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, proxy_id: &str, name: Option, proxy_settings: Option, @@ -399,7 +400,7 @@ impl ProxyManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } @@ -453,7 +454,7 @@ impl ProxyManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } @@ -489,27 +490,6 @@ impl ProxyManager { url } - // Validate IP address (IPv4 or IPv6) - fn validate_ip(ip: &str) -> bool { - // IPv4 validation - if ip.matches('.').count() == 3 { - let parts: Vec<&str> = ip.split('.').collect(); - if parts.len() == 4 { - return parts.iter().all(|part| part.parse::().is_ok()); - } - } - - // IPv6 validation (simplified - checks for colons and hex digits) - if ip.matches(':').count() >= 2 { - let parts: Vec<&str> = ip.split(':').collect(); - return parts - .iter() - .all(|part| part.is_empty() || part.chars().all(|c| c.is_ascii_hexdigit())); - } - - false - } - // Check if a proxy is valid by making HTTP requests through it pub async fn check_proxy_validity( &self, @@ -518,67 +498,10 @@ impl ProxyManager { ) -> Result { let proxy_url = Self::build_proxy_url(proxy_settings); - // List of IP check endpoints to try - let ip_check_urls = vec![ - "https://api.ipify.org", - "https://checkip.amazonaws.com", - "https://ipinfo.io/ip", - "https://icanhazip.com", - "https://ifconfig.co/ip", - ]; - - // Create HTTP client with proxy - // reqwest::Proxy::all expects http/https URLs, but we need to handle socks proxies differently - let proxy = match proxy_settings.proxy_type.as_str() { - "socks4" | "socks5" => { - // For SOCKS proxies, reqwest doesn't support them directly via Proxy::all - // We'll need to use a different approach or return an error - return Err("SOCKS proxy validation not yet supported".to_string()); - } - _ => reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Failed to create proxy: {e}"))?, - }; - - let client = reqwest::Client::builder() - .proxy(proxy) - .timeout(std::time::Duration::from_secs(5)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {e}"))?; - - // Try each endpoint until one succeeds - let mut last_error = None; - let mut ip: Option = None; - - for url_str in ip_check_urls { - match client.get(url_str).send().await { - Ok(response) => { - if response.status().is_success() { - match response.text().await { - Ok(ip_text) => { - let ip_str = ip_text.trim(); - if Self::validate_ip(ip_str) { - ip = Some(ip_str.to_string()); - break; - } else { - last_error = Some(format!("Invalid IP address returned: {ip_str}")); - } - } - Err(e) => { - last_error = Some(format!("Failed to read response from {url_str}: {e}")); - } - } - } else { - last_error = Some(format!("HTTP error from {url_str}: {}", response.status())); - } - } - Err(e) => { - last_error = Some(format!("Request to {url_str} failed: {e}")); - } - } - } - - let ip = match ip { - Some(ip) => ip, - None => { + // Fetch public IP through the proxy using shared IP utilities + let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await { + Ok(ip) => ip, + Err(e) => { // Save failed check result let failed_result = ProxyCheckResult { ip: String::new(), @@ -589,9 +512,7 @@ impl ProxyManager { is_valid: false, }; let _ = self.save_proxy_check_cache(proxy_id, &failed_result); - return Err( - last_error.unwrap_or_else(|| "Failed to get public IP from any endpoint".to_string()), - ); + return Err(format!("Failed to fetch public IP: {e}")); } }; @@ -889,7 +810,7 @@ impl ProxyManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } @@ -947,7 +868,7 @@ impl ProxyManager { map.remove(profile_id); // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } @@ -974,7 +895,7 @@ impl ProxyManager { // Only clean up orphaned config files where the proxy process itself is dead pub async fn cleanup_dead_proxies( &self, - app_handle: tauri::AppHandle, + _app_handle: tauri::AppHandle, ) -> Result, String> { // Don't stop proxies for dead browser processes - let them run indefinitely // The proxy processes are idle and don't consume CPU when not in use @@ -1075,7 +996,7 @@ impl ProxyManager { } // Emit event for reactive UI updates - if let Err(e) = app_handle.emit("proxies-changed", ()) { + if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 0baa16b..4f0ea4b 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -46,6 +46,10 @@ pub struct AppSettings { pub commercial_trial_acknowledged: bool, // Has user dismissed the trial expiration modal #[serde(default)] pub mcp_enabled: bool, // Enable MCP (Model Context Protocol) server + #[serde(default)] + pub mcp_port: Option, // Port for MCP server (default 51080) + #[serde(default)] + pub mcp_token: Option, // Displayed token for user to copy (not persisted, loaded from encrypted file) } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -75,6 +79,8 @@ impl Default for AppSettings { first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_enabled: false, + mcp_port: None, + mcp_token: None, } } } @@ -400,6 +406,162 @@ impl SettingsManager { Ok(()) } + pub async fn generate_mcp_token( + &self, + app_handle: &tauri::AppHandle, + ) -> Result> { + let token_bytes: [u8; 32] = { + use rand::RngCore; + let mut rng = rand::rng(); + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + bytes + }; + use base64::{engine::general_purpose, Engine as _}; + let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes); + self.store_mcp_token(app_handle, &token).await?; + Ok(token) + } + + pub async fn store_mcp_token( + &self, + _app_handle: &tauri::AppHandle, + token: &str, + ) -> Result<(), Box> { + let token_file = self.get_settings_dir().join("mcp_token.dat"); + + if let Some(parent) = token_file.parent() { + std::fs::create_dir_all(parent)?; + } + + let vault_password = Self::get_vault_password(); + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length")?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, token.as_bytes()) + .map_err(|e| format!("Encryption failed: {e}"))?; + + let mut file_data = Vec::new(); + file_data.extend_from_slice(b"DBMCP"); // 5-byte header for MCP token + file_data.push(2u8); // Version 2 (Argon2 + AES-GCM) + let salt_str = salt.as_str(); + file_data.push(salt_str.len() as u8); + file_data.extend_from_slice(salt_str.as_bytes()); + file_data.extend_from_slice(&nonce); + file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); + file_data.extend_from_slice(&ciphertext); + + std::fs::write(token_file, file_data)?; + Ok(()) + } + + pub async fn get_mcp_token( + &self, + _app_handle: &tauri::AppHandle, + ) -> Result, Box> { + let token_file = self.get_settings_dir().join("mcp_token.dat"); + + if !token_file.exists() { + return Ok(None); + } + + let file_data = std::fs::read(token_file)?; + + if file_data.len() < 6 || &file_data[0..5] != b"DBMCP" { + return Ok(None); + } + + let version = file_data[5]; + if version != 2 { + return Ok(None); + } + + let mut offset = 6; + if offset >= file_data.len() { + return Ok(None); + } + let salt_len = file_data[offset] as usize; + offset += 1; + + if offset + salt_len > file_data.len() { + return Ok(None); + } + let salt_bytes = &file_data[offset..offset + salt_len]; + let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; + let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; + offset += salt_len; + + if offset + 12 > file_data.len() { + return Ok(None); + } + let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] + .try_into() + .map_err(|_| "Invalid nonce length")?; + let nonce = Nonce::from(nonce_bytes); + offset += 12; + + if offset + 4 > file_data.len() { + return Ok(None); + } + let ciphertext_len = u32::from_le_bytes([ + file_data[offset], + file_data[offset + 1], + file_data[offset + 2], + file_data[offset + 3], + ]) as usize; + offset += 4; + + if offset + ciphertext_len > file_data.len() { + return Ok(None); + } + let ciphertext = &file_data[offset..offset + ciphertext_len]; + + let vault_password = Self::get_vault_password(); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length")?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + let plaintext = cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| "Decryption failed")?; + + match String::from_utf8(plaintext) { + Ok(token) => Ok(Some(token)), + Err(_) => Ok(None), + } + } + + pub async fn remove_mcp_token( + &self, + _app_handle: &tauri::AppHandle, + ) -> Result<(), Box> { + let token_file = self.get_settings_dir().join("mcp_token.dat"); + + if token_file.exists() { + std::fs::remove_file(token_file)?; + } + + Ok(()) + } + pub async fn store_sync_token( &self, _app_handle: &tauri::AppHandle, @@ -564,12 +726,17 @@ pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result Result { let manager = SettingsManager::instance(); + // Handle API token if settings.api_enabled { if let Some(ref token) = settings.api_token { manager @@ -595,7 +763,6 @@ pub async fn save_app_settings( } } - // If API is being disabled, remove the token if !settings.api_enabled { manager .remove_api_token(&app_handle) @@ -604,8 +771,33 @@ pub async fn save_app_settings( settings.api_token = None; } + // Handle MCP token + if settings.mcp_enabled { + if let Some(ref token) = settings.mcp_token { + manager + .store_mcp_token(&app_handle, token) + .await + .map_err(|e| format!("Failed to store MCP token: {e}"))?; + } else { + let token = manager + .generate_mcp_token(&app_handle) + .await + .map_err(|e| format!("Failed to generate MCP token: {e}"))?; + settings.mcp_token = Some(token); + } + } + + if !settings.mcp_enabled { + manager + .remove_mcp_token(&app_handle) + .await + .map_err(|e| format!("Failed to remove MCP token: {e}"))?; + settings.mcp_token = None; + } + let mut persist_settings = settings.clone(); persist_settings.api_token = None; + persist_settings.mcp_token = None; manager .save_settings(&persist_settings) .map_err(|e| format!("Failed to save settings: {e}"))?; @@ -765,6 +957,8 @@ mod tests { first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_enabled: false, + mcp_port: None, + mcp_token: None, }; // Save settings diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 6f4961f..51abe29 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1,6 +1,7 @@ use super::client::SyncClient; use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest}; use super::types::*; +use crate::events; use crate::profile::types::BrowserProfile; use crate::profile::ProfileManager; use crate::settings_manager::SettingsManager; @@ -9,7 +10,6 @@ use std::collections::HashMap; use std::fs; use std::path::Path; use std::sync::Arc; -use tauri::Emitter; use tokio::sync::Semaphore; pub struct SyncEngine { @@ -58,7 +58,7 @@ impl SyncEngine { profile_id ); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -93,7 +93,7 @@ impl SyncEngine { if diff.is_empty() { log::info!("Profile {} is already in sync", profile_id); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -170,9 +170,9 @@ impl SyncEngine { .as_secs(), ); let _ = profile_manager.save_profile(&updated_profile); - let _ = app_handle.emit("profiles-changed", ()); + let _ = events::emit("profiles-changed", ()); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -245,7 +245,7 @@ impl SyncEngine { async fn upload_profile_files( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], @@ -326,7 +326,7 @@ impl SyncEngine { let _ = handle.await; } - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-progress", serde_json::json!({ "profile_id": profile_id, @@ -341,7 +341,7 @@ impl SyncEngine { async fn download_profile_files( &self, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, profile_id: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], @@ -416,7 +416,7 @@ impl SyncEngine { let _ = handle.await; } - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-progress", serde_json::json!({ "profile_id": profile_id, @@ -554,9 +554,9 @@ impl SyncEngine { })?; // Emit event for UI update - if let Some(handle) = app_handle { - let _ = handle.emit("stored-proxies-changed", ()); - let _ = handle.emit( + if let Some(_handle) = app_handle { + let _ = events::emit("stored-proxies-changed", ()); + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -682,9 +682,9 @@ impl SyncEngine { } // Emit event for UI update - if let Some(handle) = app_handle { - let _ = handle.emit("groups-changed", ()); - let _ = handle.emit( + if let Some(_handle) = app_handle { + let _ = events::emit("groups-changed", ()); + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, @@ -856,8 +856,8 @@ impl SyncEngine { .save_profile(&profile) .map_err(|e| SyncError::IoError(format!("Failed to save downloaded profile: {e}")))?; - let _ = app_handle.emit("profiles-changed", ()); - let _ = app_handle.emit( + let _ = events::emit("profiles-changed", ()); + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -959,7 +959,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool { /// Enable sync for proxy if not already enabled pub async fn enable_proxy_sync_if_needed( proxy_id: &str, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, ) -> Result<(), String> { let proxy_manager = &crate::proxy_manager::PROXY_MANAGER; let proxies = proxy_manager.get_stored_proxies(); @@ -978,7 +978,7 @@ pub async fn enable_proxy_sync_if_needed( std::fs::write(&proxy_file, &json) .map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?; - let _ = app_handle.emit("stored-proxies-changed", ()); + let _ = events::emit("stored-proxies-changed", ()); log::info!("Auto-enabled sync for proxy {}", proxy_id); } @@ -988,7 +988,7 @@ pub async fn enable_proxy_sync_if_needed( /// Enable sync for group if not already enabled pub async fn enable_group_sync_if_needed( group_id: &str, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, ) -> Result<(), String> { let group = { let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap(); @@ -1011,7 +1011,7 @@ pub async fn enable_group_sync_if_needed( } } - let _ = app_handle.emit("groups-changed", ()); + let _ = events::emit("groups-changed", ()); log::info!("Auto-enabled sync for group {}", group_id); } @@ -1044,7 +1044,7 @@ pub async fn set_profile_sync_enabled( .map_err(|e| format!("Failed to load settings: {e}"))?; if settings.sync_server_url.is_none() { - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -1057,7 +1057,7 @@ pub async fn set_profile_sync_enabled( let token = manager.get_sync_token(&app_handle).await.ok().flatten(); if token.is_none() { - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -1079,13 +1079,13 @@ pub async fn set_profile_sync_enabled( .save_profile(&profile) .map_err(|e| format!("Failed to save profile: {e}"))?; - let _ = app_handle.emit("profiles-changed", ()); + let _ = events::emit("profiles-changed", ()); if enabled { // Check if profile is running to determine status let is_running = profile.process_id.is_some(); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -1118,7 +1118,7 @@ pub async fn set_profile_sync_enabled( log::warn!("Scheduler not initialized, sync will not start"); } } else { - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -1132,7 +1132,7 @@ pub async fn set_profile_sync_enabled( #[tauri::command] pub async fn request_profile_sync( - app_handle: tauri::AppHandle, + _app_handle: tauri::AppHandle, profile_id: String, ) -> Result<(), String> { // Validate profile exists and sync is enabled @@ -1155,7 +1155,7 @@ pub async fn request_profile_sync( // Queue sync via scheduler if let Some(scheduler) = super::get_global_scheduler() { let is_running = profile.process_id.is_some(); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -1251,10 +1251,10 @@ pub async fn set_proxy_sync_enabled( std::fs::write(&proxy_file, &json) .map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?; - let _ = app_handle.emit("stored-proxies-changed", ()); + let _ = events::emit("stored-proxies-changed", ()); if enabled { - let _ = app_handle.emit( + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -1266,7 +1266,7 @@ pub async fn set_proxy_sync_enabled( scheduler.queue_proxy_sync(proxy_id).await; } } else { - let _ = app_handle.emit( + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -1330,10 +1330,10 @@ pub async fn set_group_sync_enabled( } } - let _ = app_handle.emit("groups-changed", ()); + let _ = events::emit("groups-changed", ()); if enabled { - let _ = app_handle.emit( + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, @@ -1345,7 +1345,7 @@ pub async fn set_group_sync_enabled( scheduler.queue_group_sync(group_id).await; } } else { - let _ = app_handle.emit( + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 25814ca..3b1189a 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -1,12 +1,12 @@ use super::engine::SyncEngine; use super::subscription::SyncWorkItem; +use crate::events; use crate::profile::ProfileManager; use once_cell::sync::OnceCell; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -use tauri::Emitter; use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::time::sleep; @@ -204,7 +204,7 @@ impl SyncScheduler { } } - pub async fn sync_all_enabled_profiles(&self, app_handle: &tauri::AppHandle) { + pub async fn sync_all_enabled_profiles(&self, _app_handle: &tauri::AppHandle) { log::info!("Starting initial sync for all enabled profiles..."); let profiles = { @@ -235,7 +235,7 @@ impl SyncScheduler { let is_running = profile.process_id.is_some(); // Emit initial status - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -324,7 +324,7 @@ impl SyncScheduler { } log::info!("Executing queued sync for profile {}", profile_id); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -370,7 +370,7 @@ impl SyncScheduler { match result { Ok(()) => { log::info!("Profile {} synced successfully", profile_id); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -380,7 +380,7 @@ impl SyncScheduler { } Err(e) => { log::error!("Failed to sync profile {}: {}", profile_id, e); - let _ = app_handle.emit( + let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, @@ -419,7 +419,7 @@ impl SyncScheduler { Ok(engine) => { for proxy_id in proxies_to_sync { log::info!("Syncing proxy {}", proxy_id); - let _ = app_handle.emit( + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -431,7 +431,7 @@ impl SyncScheduler { .await { Ok(()) => { - let _ = app_handle.emit( + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -441,7 +441,7 @@ impl SyncScheduler { } Err(e) => { log::error!("Failed to sync proxy {}: {}", proxy_id, e); - let _ = app_handle.emit( + let _ = events::emit( "proxy-sync-status", serde_json::json!({ "id": proxy_id, @@ -485,7 +485,7 @@ impl SyncScheduler { Ok(engine) => { for group_id in groups_to_sync { log::info!("Syncing group {}", group_id); - let _ = app_handle.emit( + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, @@ -497,7 +497,7 @@ impl SyncScheduler { .await { Ok(()) => { - let _ = app_handle.emit( + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, @@ -507,7 +507,7 @@ impl SyncScheduler { } Err(e) => { log::error!("Failed to sync group {}: {}", group_id, e); - let _ = app_handle.emit( + let _ = events::emit( "group-sync-status", serde_json::json!({ "id": group_id, diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index 5233002..654abe5 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -1,10 +1,10 @@ +use crate::events; use crate::settings_manager::SettingsManager; use reqwest::Client; use serde::Deserialize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use tauri::Emitter; use tokio::sync::mpsc; use tokio::time::sleep; @@ -122,7 +122,7 @@ impl SyncSubscription { token: &str, work_tx: &mpsc::UnboundedSender, running: &Arc, - app_handle: &tauri::AppHandle, + _app_handle: &tauri::AppHandle, ) -> Result<(), String> { let url = format!("{base_url}/v1/objects/subscribe"); @@ -142,7 +142,7 @@ impl SyncSubscription { } log::info!("Connected to sync subscription at {url}"); - let _ = app_handle.emit("sync-subscription-status", "connected"); + let _ = events::emit("sync-subscription-status", "connected"); let mut buffer = String::new(); let mut bytes_stream = response.bytes_stream(); diff --git a/src-tauri/src/version_updater.rs b/src-tauri/src/version_updater.rs index 2def9d4..ac7a52d 100644 --- a/src-tauri/src/version_updater.rs +++ b/src-tauri/src/version_updater.rs @@ -5,12 +5,12 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tauri::Emitter; use tokio::sync::Mutex; use tokio::time::interval; use crate::auto_updater::AutoUpdater; use crate::browser_version_manager::BrowserVersionManager; +use crate::events; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct VersionUpdateProgress { @@ -244,7 +244,7 @@ impl VersionUpdater { // Try to emit error event if we have an app handle let updater_guard = updater.lock().await; - if let Some(ref app_handle) = updater_guard.app_handle { + if let Some(ref _app_handle) = updater_guard.app_handle { let progress = VersionUpdateProgress { current_browser: "".to_string(), total_browsers: 0, @@ -253,7 +253,7 @@ impl VersionUpdater { browser_new_versions: 0, status: "error".to_string(), }; - let _ = app_handle.emit("version-update-progress", &progress); + let _ = events::emit("version-update-progress", &progress); } } } @@ -279,7 +279,7 @@ impl VersionUpdater { status: "updating".to_string(), }; - if let Err(e) = app_handle.emit("version-update-progress", &initial_progress) { + if let Err(e) = events::emit("version-update-progress", &initial_progress) { log::error!("Failed to emit initial progress: {e}"); } @@ -296,7 +296,7 @@ impl VersionUpdater { status: "updating".to_string(), }; - if let Err(e) = app_handle.emit("version-update-progress", &progress) { + if let Err(e) = events::emit("version-update-progress", &progress) { log::error!("Failed to emit progress for {browser}: {e}"); } @@ -322,7 +322,7 @@ impl VersionUpdater { status: "updating".to_string(), }; - if let Err(e) = app_handle.emit("version-update-progress", &progress) { + if let Err(e) = events::emit("version-update-progress", &progress) { log::error!("Failed to emit progress with versions for {browser}: {e}"); } } @@ -348,7 +348,7 @@ impl VersionUpdater { status: "completed".to_string(), }; - if let Err(e) = app_handle.emit("version-update-progress", &final_progress) { + if let Err(e) = events::emit("version-update-progress", &final_progress) { eprintln!("Failed to emit completion progress: {e}"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1a4e187..8cbaeab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "active": true, "targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"], "category": "Productivity", - "externalBin": ["binaries/donut-proxy"], + "externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/app/page.tsx b/src/app/page.tsx index 7308d73..462c81b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,6 +14,7 @@ import { GroupBadges } from "@/components/group-badges"; import { GroupManagementDialog } from "@/components/group-management-dialog"; import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; +import { IntegrationsDialog } from "@/components/integrations-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; @@ -88,6 +89,7 @@ export default function Home() { const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); const [proxyManagementDialogOpen, setProxyManagementDialogOpen] = useState(false); @@ -805,6 +807,7 @@ export default function Home() { onProxyManagementDialogOpen={setProxyManagementDialogOpen} onSettingsDialogOpen={setSettingsDialogOpen} onSyncConfigDialogOpen={setSyncConfigDialogOpen} + onIntegrationsDialogOpen={setIntegrationsDialogOpen} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} /> @@ -855,6 +858,17 @@ export default function Home() { onClose={() => { setSettingsDialogOpen(false); }} + onIntegrationsOpen={() => { + setSettingsDialogOpen(false); + setIntegrationsDialogOpen(true); + }} + /> + + { + setIntegrationsDialogOpen(false); + }} /> void; onCreateProfileDialogOpen: (open: boolean) => void; onSyncConfigDialogOpen: (open: boolean) => void; + onIntegrationsDialogOpen: (open: boolean) => void; searchQuery: string; onSearchQueryChange: (query: string) => void; }; @@ -32,6 +33,7 @@ const HomeHeader = ({ onImportProfileDialogOpen, onCreateProfileDialogOpen, onSyncConfigDialogOpen, + onIntegrationsDialogOpen, searchQuery, onSearchQueryChange, }: Props) => { @@ -128,6 +130,14 @@ const HomeHeader = ({ Sync Service + { + onIntegrationsDialogOpen(true); + }} + > + + Integrations + { onImportProfileDialogOpen(true); diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx new file mode 100644 index 0000000..d2506c4 --- /dev/null +++ b/src/components/integrations-dialog.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { Eye, EyeOff } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { CopyToClipboard } from "./ui/copy-to-clipboard"; + +interface AppSettings { + api_enabled: boolean; + api_port: number; + api_token?: string; + mcp_enabled: boolean; + mcp_port?: number; + mcp_token?: string; +} + +interface McpConfig { + port: number; + token: string; + config_json: string; +} + +interface IntegrationsDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function IntegrationsDialog({ + isOpen, + onClose, +}: IntegrationsDialogProps) { + const [settings, setSettings] = useState({ + api_enabled: false, + api_port: 10108, + api_token: undefined, + mcp_enabled: false, + mcp_port: undefined, + mcp_token: undefined, + }); + const [apiServerPort, setApiServerPort] = useState(null); + const [mcpConfig, setMcpConfig] = useState(null); + const [_mcpRunning, setMcpRunning] = useState(false); + const [showApiToken, setShowApiToken] = useState(false); + const [showMcpToken, setShowMcpToken] = useState(false); + const [isApiStarting, setIsApiStarting] = useState(false); + const [isMcpStarting, setIsMcpStarting] = useState(false); + + const { termsAccepted } = useWayfernTerms(); + + const loadSettings = useCallback(async () => { + try { + const loaded = await invoke("get_app_settings"); + setSettings(loaded); + } catch (e) { + console.error("Failed to load settings:", e); + } + }, []); + + const loadMcpConfig = useCallback(async () => { + try { + const config = await invoke("get_mcp_config"); + setMcpConfig(config); + } catch (e) { + console.error("Failed to get MCP config:", e); + } + }, []); + + const loadMcpServerStatus = useCallback(async () => { + try { + const isRunning = await invoke("get_mcp_server_status"); + setMcpRunning(isRunning); + } catch (e) { + console.error("Failed to get MCP server status:", e); + } + }, []); + + const loadApiServerStatus = useCallback(async () => { + try { + const port = await invoke("get_api_server_status"); + setApiServerPort(port); + } catch (e) { + console.error("Failed to get API server status:", e); + } + }, []); + + useEffect(() => { + if (isOpen) { + loadSettings(); + loadApiServerStatus(); + loadMcpConfig(); + loadMcpServerStatus(); + } + }, [ + isOpen, + loadSettings, + loadApiServerStatus, + loadMcpConfig, + loadMcpServerStatus, + ]); + + const handleApiToggle = async (enabled: boolean) => { + setIsApiStarting(true); + try { + if (enabled) { + const port = await invoke("start_api_server", { + port: settings.api_port, + }); + setApiServerPort(port); + const next = await invoke("save_app_settings", { + settings: { ...settings, api_enabled: true }, + }); + setSettings(next); + showSuccessToast(`API server started on port ${port}`); + } else { + await invoke("stop_api_server"); + setApiServerPort(null); + const next = await invoke("save_app_settings", { + settings: { ...settings, api_enabled: false, api_token: null }, + }); + setSettings(next); + showSuccessToast("API server stopped"); + } + } catch (e) { + console.error("Failed to toggle API:", e); + showErrorToast("Failed to toggle API server", { + description: e instanceof Error ? e.message : "Unknown error", + }); + } finally { + setIsApiStarting(false); + } + }; + + const handleMcpToggle = async (enabled: boolean) => { + setIsMcpStarting(true); + try { + if (enabled) { + const port = await invoke("start_mcp_server"); + const next = await invoke("save_app_settings", { + settings: { ...settings, mcp_enabled: true, mcp_port: port }, + }); + setSettings(next); + loadMcpConfig(); + showSuccessToast(`MCP server started on port ${port}`); + } else { + await invoke("stop_mcp_server"); + const next = await invoke("save_app_settings", { + settings: { ...settings, mcp_enabled: false }, + }); + setSettings(next); + setMcpConfig(null); + showSuccessToast("MCP server stopped"); + } + } catch (e) { + console.error("Failed to toggle MCP server:", e); + showErrorToast("Failed to toggle MCP server", { + description: e instanceof Error ? e.message : "Unknown error", + }); + } finally { + setIsMcpStarting(false); + } + }; + + const obfuscateToken = (token: string) => + "•".repeat(Math.min(token.length, 32)); + + const getFormattedMcpConfig = () => { + if (!mcpConfig) return ""; + return JSON.stringify( + { + mcpServers: { + "donut-browser": { + url: `http://127.0.0.1:${mcpConfig.port}/mcp`, + headers: { + Authorization: `Bearer ${mcpConfig.token}`, + }, + }, + }, + }, + null, + 2, + ); + }; + + const getObfuscatedMcpConfig = () => { + if (!mcpConfig) return ""; + return JSON.stringify( + { + mcpServers: { + "donut-browser": { + url: `http://127.0.0.1:${mcpConfig.port}/mcp`, + headers: { + Authorization: `Bearer ${obfuscateToken(mcpConfig.token)}`, + }, + }, + }, + }, + null, + 2, + ); + }; + + return ( + !open && onClose()}> + + + Integrations + + + + + Local API + MCP (AI Assistants) + + + +
+ +
+ +

+ Allow managing profiles, groups, and proxies via REST API. +

+
+
+ + {settings.api_enabled && ( +
+
+ +
+ + + Server is running + +
+
+ +
+ +
+
+ + +
+ +
+

+ Include in Authorization header: Bearer {""} +

+
+
+ )} +
+ + +
+ +
+ +

+ Allow AI assistants like Claude Desktop to control browsers. + {!termsAccepted && ( + + (Accept Wayfern terms in Settings first) + + )} +

+
+
+ + {mcpConfig && ( +
+
+ +

+ Copy this configuration to your Claude Desktop config file + at{" "} + + ~/.config/claude/claude_desktop_config.json + +

+
+ +
+
+                    {showMcpToken
+                      ? getFormattedMcpConfig()
+                      : getObfuscatedMcpConfig()}
+                  
+
+ + +
+
+ +
+ +
    +
  • list_profiles - List browser profiles
  • +
  • run_profile - Launch a browser
  • +
  • kill_profile - Stop a running browser
  • +
  • get_profile_status - Check if browser is running
  • +
  • list_groups, create_group, etc. - Manage groups
  • +
  • list_proxies, create_proxy, etc. - Manage proxies
  • +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index ce18e94..ae29a15 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -68,6 +68,7 @@ import { getBrowserIcon, getCurrentOS, } from "@/lib/browser-utils"; +import { formatRelativeTime } from "@/lib/flag-utils"; import { trimName } from "@/lib/name-utils"; import { cn } from "@/lib/utils"; import type { @@ -1911,59 +1912,26 @@ export function ProfilesDataTable({ id: "sync", header: "", size: 24, - cell: ({ row, table }) => { - const meta = table.options.meta as TableMeta; + cell: ({ row }) => { const profile = row.original; - if (!profile.sync_enabled) { + if (!profile.sync_enabled && profile.last_sync) { return ( - + - Sync disabled + + Sync is disabled, last sync{" "} + {formatRelativeTime(profile.last_sync)} + ); } - const syncStatus = meta.syncStatuses[profile.id]; - const isSyncing = syncStatus === "syncing"; - const isWaiting = syncStatus === "waiting"; - const isSynced = - syncStatus === "synced" || (!syncStatus && profile.last_sync); - const isError = syncStatus === "error"; - - let dotClass = "bg-yellow-500"; - let tooltipText = "Sync pending"; - - if (isSyncing) { - dotClass = "bg-yellow-500 animate-pulse"; - tooltipText = "Syncing..."; - } else if (isWaiting) { - dotClass = "bg-yellow-500"; - tooltipText = "Waiting for profile to stop"; - } else if (isError) { - dotClass = "bg-red-500"; - tooltipText = "Sync error"; - } else if (isSynced) { - dotClass = "bg-green-500"; - tooltipText = profile.last_sync - ? `Last synced: ${new Date(profile.last_sync * 1000).toLocaleString()}` - : "Synced"; - } - - return ( - - - - - - - {tooltipText} - - ); + return null; }, }, { @@ -2031,25 +1999,6 @@ export function ProfilesDataTable({ Copy Cookies to Profile
)} - {meta.onOpenProfileSyncDialog && ( - { - meta.onOpenProfileSyncDialog?.(profile); - }} - > - Sync Settings - - )} - {meta.onToggleProfileSync && ( - { - meta.onToggleProfileSync?.(profile); - }} - disabled={isDisabled} - > - {profile.sync_enabled ? "Disable Sync" : "Enable Sync"} - - )} { setProfileToDelete(profile); diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 439597b..f2e9545 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from "react"; import { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; import { ColorPicker, ColorPickerAlpha, @@ -40,7 +39,6 @@ import { import { useCommercialTrial } from "@/hooks/use-commercial-trial"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; -import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { getThemeByColors, getThemeById, @@ -48,7 +46,6 @@ import { THEMES, } from "@/lib/themes"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import { CopyToClipboard } from "./ui/copy-to-clipboard"; import { RippleButton } from "./ui/ripple"; interface AppSettings { @@ -76,9 +73,14 @@ interface PermissionInfo { interface SettingsDialogProps { isOpen: boolean; onClose: () => void; + onIntegrationsOpen?: () => void; } -export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { +export function SettingsDialog({ + isOpen, + onClose, + onIntegrationsOpen, +}: SettingsDialogProps) { const [settings, setSettings] = useState({ set_as_default_browser: false, theme: "system", @@ -109,7 +111,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); - const [apiServerPort, setApiServerPort] = useState(null); const { setTheme } = useTheme(); const { @@ -117,10 +118,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { isMicrophoneAccessGranted, isCameraAccessGranted, } = usePermissions(); - const { termsAccepted } = useWayfernTerms(); const { trialStatus } = useCommercialTrial(); - const [mcpEnabled, setMcpEnabled] = useState(false); - const [isMcpStarting, setIsMcpStarting] = useState(false); const getPermissionIcon = useCallback((type: PermissionType) => { switch (type) { @@ -352,48 +350,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } catch {} } - // Handle API server start/stop based on settings - const wasApiEnabled = originalSettings.api_enabled; - const isApiEnabled = settingsToSave.api_enabled; - - if (isApiEnabled && !wasApiEnabled) { - // Start API server - try { - const port = await invoke("start_api_server", { - port: settingsToSave.api_port, - }); - setApiServerPort(port); - showSuccessToast(`Local API started on port ${port}`); - } catch (error) { - console.error("Failed to start API server:", error); - showErrorToast("Failed to start API server", { - description: - error instanceof Error ? error.message : "Unknown error occurred", - }); - // Revert the API enabled setting if start failed - settingsToSave.api_enabled = false; - const revertedSettings = await invoke( - "save_app_settings", - { settings: settingsToSave }, - ); - setSettings(revertedSettings); - settingsToSave = revertedSettings; - } - } else if (!isApiEnabled && wasApiEnabled) { - // Stop API server - try { - await invoke("stop_api_server"); - setApiServerPort(null); - showSuccessToast("Local API stopped"); - } catch (error) { - console.error("Failed to stop API server:", error); - showErrorToast("Failed to stop API server", { - description: - error instanceof Error ? error.message : "Unknown error occurred", - }); - } - } - setOriginalSettings(settingsToSave); onClose(); } catch (error) { @@ -401,7 +357,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } finally { setIsSaving(false); } - }, [onClose, setTheme, settings, customThemeState, originalSettings]); + }, [onClose, setTheme, settings, customThemeState]); const updateSetting = useCallback( ( @@ -413,26 +369,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { [], ); - const loadApiServerStatus = useCallback(async () => { - try { - const port = await invoke("get_api_server_status"); - setApiServerPort(port); - } catch (error) { - console.error("Failed to load API server status:", error); - setApiServerPort(null); - } - }, []); - - const loadMcpServerStatus = useCallback(async () => { - try { - const isRunning = await invoke("get_mcp_server_status"); - setMcpEnabled(isRunning); - } catch (error) { - console.error("Failed to load MCP server status:", error); - setMcpEnabled(false); - } - }, []); - const handleClose = useCallback(() => { // Restore original theme when closing without saving if (originalSettings.theme === "custom" && originalSettings.custom_theme) { @@ -470,8 +406,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { if (isOpen) { loadSettings().catch(console.error); checkDefaultBrowserStatus().catch(console.error); - loadApiServerStatus().catch(console.error); - loadMcpServerStatus().catch(console.error); // Check if we're on macOS const userAgent = navigator.userAgent; @@ -492,14 +426,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { clearInterval(intervalId); }; } - }, [ - isOpen, - loadPermissions, - checkDefaultBrowserStatus, - loadSettings, - loadApiServerStatus, - loadMcpServerStatus, - ]); + }, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]); // Update permissions when the permission states change useEffect(() => { @@ -790,279 +717,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { )} - {/* Local API Section */} + {/* Integrations Section */}
- - -
- { - updateSetting("api_enabled", checked); - try { - if (checked) { - // Ask backend to enable API and return settings with token - const next = await invoke( - "save_app_settings", - { - settings: { ...settings, api_enabled: true }, - }, - ); - setSettings(next); - } else { - const next = await invoke( - "save_app_settings", - { - settings: { - ...settings, - api_enabled: false, - api_token: null, - }, - }, - ); - setSettings(next); - } - } catch (e) { - console.error("Failed to toggle API:", e); - } - }} - /> -
- -

- Allow managing the application data externally via REST API. - Server will start on port 10108 or a random port if - unavailable. - {apiServerPort && ( - - (Currently running on port {apiServerPort}) - - )} -

-
-
- - {settings.api_enabled && settings.api_token && ( -
- -
- - -
-

- Include this token in the Authorization header as "Bearer{" "} - {settings.api_token}" for all API requests. -

- {/* Temporary in-app API docs */} -
-
- Temporary in-app API docs (alpha) -
-
-
- Base URL:{" "} - {`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`} -
-
- Auth:{" "} - - Authorization: Bearer {settings.api_token} - -
-
-
-
Profiles
-
    -
  • - GET /profiles — list - profiles -
  • -
  • - - GET /profiles/{"{"}id{"}"} - {" "} - — get one -
  • -
  • - POST /profiles — - create - - (required: name, browser, version; optional: - release_type, proxy_id, camoufox_config, group_id, - tags) - -
  • -
  • - - PUT /profiles/{"{"}id{"}"} - {" "} - — update - - (any of: name, version, proxy_id, camoufox_config, - group_id, tags) - -
  • -
  • - - DELETE /profiles/{"{"}id{"}"} - {" "} - — delete -
  • -
  • - - POST /profiles/{"{"}id{"}"}/run - {" "} - — launch with remote debugging - - (body: {"{"}url?, headless?{"}"}) - -
  • -
  • - - POST /profiles/{"{"}id{"}"}/open-url - {" "} - — open URL in running profile - - (body: {"{"}url{"}"}) - -
  • -
  • - - POST /profiles/{"{"}id{"}"}/kill - {" "} - — stop browser process -
  • -
-
-
-
Groups
-
    -
  • - GET /groups — list -
  • -
  • - - GET /groups/{"{"}id{"}"} - {" "} - — get one -
  • -
  • - POST /groups — create - - (required: name) - -
  • -
  • - - PUT /groups/{"{"}id{"}"} - {" "} - — rename - - (required: name) - -
  • -
  • - - DELETE /groups/{"{"}id{"}"} - {" "} - — delete -
  • -
-
-
-
Tags
-
    -
  • - GET /tags — list -
  • -
-
-
-
Proxies
-
    -
  • - GET /proxies — list -
  • -
  • - - GET /proxies/{"{"}id{"}"} - {" "} - — get one -
  • -
  • - POST /proxies — - create - - (required: name, proxy_settings object) - -
  • -
  • - - PUT /proxies/{"{"}id{"}"} - {" "} - — update - - (optional: name, proxy_settings) - -
  • -
  • - - DELETE /proxies/{"{"}id{"}"} - {" "} - — delete -
  • -
-
-
-
Browsers
-
    -
  • - - POST /browsers/download - {" "} - — download - - (required: browser, version) - -
  • -
  • - - GET /browsers/{"{"}browser{"}"}/versions - {" "} - — list versions -
  • -
  • - - GET /browsers/{"{"}browser{"}"}/versions/{"{"}version - {"}"}/downloaded - {" "} - — is downloaded -
  • -
-
-
- These docs are temporary and will be replaced with full - documentation later. -
-
-
- )} + +

+ Configure Local API and MCP (Model Context Protocol) for + integrating with external tools and AI assistants. +

+ + Open Integrations Settings +
{/* Commercial License Section */} @@ -1094,71 +762,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { - {/* MCP Server Section */} -
- - -
- { - setIsMcpStarting(true); - try { - if (checked) { - await invoke("start_mcp_server"); - setMcpEnabled(true); - showSuccessToast("MCP server started"); - } else { - await invoke("stop_mcp_server"); - setMcpEnabled(false); - showSuccessToast("MCP server stopped"); - } - } catch (e) { - console.error("Failed to toggle MCP server:", e); - showErrorToast("Failed to toggle MCP server", { - description: - e instanceof Error ? e.message : "Unknown error", - }); - } finally { - setIsMcpStarting(false); - } - }} - /> -
- -

- Allow AI assistants to control Wayfern and Camoufox browsers - via MCP. - {!termsAccepted && ( - - (Accept terms first) - - )} -

-
-
- - {mcpEnabled && ( -
-
Available MCP Tools
-
    -
  • list_profiles - List Wayfern/Camoufox profiles
  • -
  • run_profile - Launch a browser profile
  • -
  • kill_profile - Stop a running browser
  • -
  • get_profile - Get profile details
  • -
  • list_proxies - List configured proxies
  • -
-
- )} -
- {/* Advanced Section */}