diff --git a/.cargo/config.toml b/.cargo/config.toml index 98f4a5639..944eac566 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,3 +6,5 @@ test-full = "test --workspace" [env] # Dictation binaries must run on machines other than the build host. GGML_NATIVE = { value = "OFF", force = false } +# Keep vendored C atomics inline for the baseline ARMv8 musl static runtime. +CFLAGS_aarch64_unknown_linux_musl = { value = "-mno-outline-atomics", force = false } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97b83dd9e..471b4465b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -838,7 +838,7 @@ jobs: if: needs.validate.outputs.publish_draft != 'true' && needs.validate.outputs.target_channel != 'stable' needs: validate runs-on: windows-latest-l - timeout-minutes: 75 + timeout-minutes: 100 env: CARGO_INCREMENTAL: "0" CARGO_TARGET_DIR: ${{ github.workspace }}\apps\desktop\src-tauri\target @@ -846,6 +846,7 @@ jobs: COLOSSUS_DESKTOP_RELEASE_VERSION: ${{ needs.validate.outputs.version }} COLOSSUS_DESKTOP_TARGET: x86_64-pc-windows-msvc COLOSSUS_DESKTOP_TEAM_ID: UNSIGNED + COLOSSUS_DESKTOP_BROWSER_FUNCTIONAL_PREVIEW: "1" COLOSSUS_DESKTOP_UPDATE_ENDPOINT: "" COLOSSUS_DESKTOP_UPDATE_PUBLIC_KEY: "" SCCACHE_GHA_ENABLED: "true" @@ -891,7 +892,10 @@ jobs: - name: Build sealed unsigned NSIS installer shell: pwsh - run: ./scripts/package-desktop-windows.ps1 + run: | + ./scripts/stage-browser-windows-preview.ps1 -Phase build + ./scripts/stage-browser-windows-preview.ps1 -Phase seal + ./scripts/package-desktop-windows.ps1 - name: Install, launch preview, uninstall, and verify process cleanup id: smoke @@ -1054,6 +1058,7 @@ jobs: COLOSSUS_DESKTOP_TEAM_ID: OBSCURITY_LABS_LLC COLOSSUS_DESKTOP_UPDATE_ENDPOINT: "" COLOSSUS_DESKTOP_UPDATE_PUBLIC_KEY: "" + COLOSSUS_DESKTOP_BROWSER_FUNCTIONAL_PREVIEW: ${{ needs.validate.outputs.release_channel == 'developer_preview' && '1' || '0' }} SCCACHE_GHA_ENABLED: "true" steps: - name: Check out repository @@ -1095,10 +1100,6 @@ jobs: npm ci --ignore-scripts npm audit --audit-level=high - - name: Build unsigned desktop inputs - shell: pwsh - run: ./scripts/package-desktop-windows.ps1 -Phase build - - name: Authenticate to Azure using GitHub OIDC uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: @@ -1106,6 +1107,47 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Build pinned functional-preview browser without launching it + if: env.COLOSSUS_DESKTOP_BROWSER_FUNCTIONAL_PREVIEW == '1' + shell: pwsh + run: | + ./scripts/stage-browser-windows-preview.ps1 -Phase build + foreach ($name in @('COLOSSUS_BROWSER_PREVIEW_ALLOCATION', 'COLOSSUS_BROWSER_PREVIEW_COMPONENT', + 'COLOSSUS_BROWSER_PRESENTER_NATIVE_LIB_DIR')) { + "$name=$([Environment]::GetEnvironmentVariable($name, 'Process'))" | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + $images = @(Get-ChildItem -LiteralPath $env:COLOSSUS_BROWSER_PREVIEW_COMPONENT -Recurse -File | + Where-Object Extension -In @('.exe', '.dll')) + if ($images.Count -lt 6 -or $images.Count -gt 64) { throw 'unexpected browser image roster' } + 'COLOSSUS_BROWSER_PREVIEW_IMAGES< **Developer Preview:** This entire release is pre-release software for testing. The macOS Desktop build is ad-hoc signed and not notarized by Apple. The Windows Desktop installer, bundled CLI, and sidecar are Authenticode signed by Obscurity Labs LLC. SmartScreen may still warn until the signed app establishes reputation. Do not treat the preview as stable or production-ready.' \ '' \ 'Desktop package names include `DEVELOPER-PREVIEW`.' \ + 'On Windows, install Desktop with the attached setup.exe. The browser preview requires the per-user installer; copied or unpacked app directories are not a supported portable installation. Windows CLI ZIPs do not include the browser.' \ 'Automatic Desktop updates are disabled in this preview. Install later previews manually from GitHub Releases.' \ "$checksum_note" \ '' \ diff --git a/Cargo.lock b/Cargo.lock index 7dabe9bb7..b644469e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,18 +298,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -322,20 +310,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - [[package]] name = "async-io" version = "2.6.0" @@ -1321,7 +1295,6 @@ dependencies = [ "getrandom 0.4.3", "hex", "hmac 0.12.1", - "keyring", "portable-pty", "rcgen", "rustix 1.1.4", @@ -1431,7 +1404,6 @@ dependencies = [ "futures", "getrandom 0.4.3", "hex", - "keyring", "openidconnect", "p256", "rcgen", @@ -1568,6 +1540,7 @@ dependencies = [ "colossus-home", "colossus-ports", "colossus-windows-native", + "dbus-secret-service", "fs4", "getrandom 0.4.3", "hex", @@ -1579,7 +1552,6 @@ dependencies = [ "subtle", "tempfile", "windows-native-keyring-store", - "zbus-secret-service-keyring-store", "zeroize", ] @@ -1718,6 +1690,7 @@ dependencies = [ "base64 0.22.1", "chacha20poly1305", "colossus-contracts", + "colossus-credentials", "colossus-memory", "colossus-ports", "colossus-projection", @@ -1729,7 +1702,6 @@ dependencies = [ "fs4", "getrandom 0.4.3", "hex", - "keyring", "redb", "serde", "serde_json", @@ -1737,6 +1709,7 @@ dependencies = [ "tempfile", "time", "uuid", + "zeroize", ] [[package]] @@ -2225,6 +2198,7 @@ dependencies = [ "colossus-api-runtime", "colossus-browser-presentation", "colossus-contracts", + "colossus-credentials", "colossus-darwin-process", "colossus-grpc", "colossus-policy", @@ -2235,7 +2209,6 @@ dependencies = [ "futures", "hex", "image", - "keyring", "nix 0.31.3", "prost", "prost-types", @@ -2295,6 +2268,7 @@ dependencies = [ "colossus-browser-presentation", "colossus-bundles", "colossus-contracts", + "colossus-credentials", "colossus-grpc", "colossus-home", "colossus-mcp", @@ -2311,7 +2285,6 @@ dependencies = [ "colossus-worker-protocol", "getrandom 0.4.3", "hex", - "keyring", "libc", "rustix 1.1.4", "serde", @@ -2921,6 +2894,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "futures-util", + "hkdf", + "num", + "once_cell", + "openssl", + "rand 0.8.7", + "sha2 0.10.9", +] + [[package]] name = "defmt" version = "1.1.1" @@ -3279,12 +3282,6 @@ dependencies = [ "encoding_rs", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - [[package]] name = "enumflags2" version = "0.7.12" @@ -3292,7 +3289,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", - "serde", ] [[package]] @@ -3491,6 +3487,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -4464,18 +4475,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "keyring" -version = "4.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - [[package]] name = "keyring-core" version = "1.0.0" @@ -4517,6 +4516,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4675,15 +4684,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -5213,12 +5213,59 @@ dependencies = [ "url", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.32.0" @@ -5332,16 +5379,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "outref" version = "0.5.2" @@ -6802,25 +6839,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secret-service" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", - "num", - "once_cell", - "serde", - "sha2 0.10.9", - "zbus", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -6986,17 +7004,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -8461,17 +8468,6 @@ dependencies = [ "typify-impl", ] -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unicase" version = "2.9.0" @@ -8616,6 +8612,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -9312,78 +9314,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.118", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" -dependencies = [ - "serde", - "winnow 1.0.3", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.54" @@ -9527,43 +9457,3 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] - -[[package]] -name = "zvariant" -version = "5.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.118", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.118", - "winnow 1.0.3", -] diff --git a/Cargo.toml b/Cargo.toml index 8ed402123..b500f5640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,6 @@ globset = "0.4.18" ignore = "0.4.28" jiff = { version = "0.2.32", default-features = false, features = ["std", "tzdb-bundle-always"] } jsonschema = "0.33.0" -keyring = "4.1.4" nono = { version = "=0.67.1", default-features = false } nix = { version = "0.31.3", features = ["sched"] } postgres = { version = "0.19.14", features = ["with-serde_json-1"] } diff --git a/apps/desktop/native-browser/Cargo.toml b/apps/desktop/native-browser/Cargo.toml index c941b2b92..7f8bfb4dc 100644 --- a/apps/desktop/native-browser/Cargo.toml +++ b/apps/desktop/native-browser/Cargo.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" workspace = "../src-tauri" [features] +windows-browser-functional-preview = ["dep:async-trait", "dep:colossus-browser-presentation", "dep:serde_json", "dep:zeroize"] cef-preview = ["dep:serde_json", "dep:sha2", "dep:rustls-pki-types", "dep:x509-parser", "dep:zeroize", "dep:colossus-native-browser-pki", "dep:colossus-browser-presentation", "dep:async-trait"] native-test-driver = ["objc2-foundation/NSData", "objc2-foundation/NSJSONSerialization"] @@ -21,13 +22,13 @@ sha2 = { version = "0.10.9", optional = true } rustls-pki-types = { version = "1.14.0", optional = true } x509-parser = { version = "0.18.1", optional = true } zeroize = { version = "1.8.2", optional = true } -tauri = { version = "=2.11.5", features = ["unstable"] } +tauri = { version = "=2.12.4", features = ["unstable"] } tokio = { version = "1.49.0", features = ["sync", "time"] } url = "2.5.8" [target.'cfg(windows)'.dependencies] -webview2-com = "=0.38.2" -windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +webview2-com = "=0.39.1" +windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows-sys = { version = "=0.61.2", features = ["Win32_Security_Cryptography", "Win32_Foundation"] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/desktop/native-browser/build.rs b/apps/desktop/native-browser/build.rs index 05503fa78..554cee679 100644 --- a/apps/desktop/native-browser/build.rs +++ b/apps/desktop/native-browser/build.rs @@ -2,7 +2,30 @@ use std::{env, path::PathBuf}; +#[path = "build/functional_preview.rs"] +mod functional_preview; + fn main() { + configure_windows_manifest(); + functional_preview::link(); + link_native_preview(); +} + +fn configure_windows_manifest() { + // Tauri's Windows dialog code imports TaskDialogIndirect. Library tests and + // standalone executables need their own Common Controls v6 activation; + // Desktop's application manifest does not propagate to dependency targets. + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + { + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" + ); + } +} + +fn link_native_preview() { println!("cargo:rustc-check-cfg=cfg(colossus_cef_linked)"); println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_NATIVE_LIB_DIR"); println!("cargo:rerun-if-env-changed=COLOSSUS_CEF_ROOT"); @@ -109,4 +132,5 @@ fn main() { _ => panic!("native CEF linkage target is unsupported"), } println!("cargo:rustc-cfg=colossus_cef_linked"); + println!("cargo:rustc-cfg=colossus_browser_presenter_linked"); } diff --git a/apps/desktop/native-browser/build/functional_preview.rs b/apps/desktop/native-browser/build/functional_preview.rs new file mode 100644 index 000000000..4dcfe8e1a --- /dev/null +++ b/apps/desktop/native-browser/build/functional_preview.rs @@ -0,0 +1,39 @@ +//! Link the pixel presenter only; the browser engine remains in its separate host. +use std::{env, path::PathBuf}; + +pub(super) fn link() { + println!("cargo:rustc-check-cfg=cfg(colossus_browser_presenter_linked)"); + println!("cargo:rerun-if-env-changed=COLOSSUS_BROWSER_PRESENTER_NATIVE_LIB_DIR"); + if env::var_os("CARGO_FEATURE_WINDOWS_BROWSER_FUNCTIONAL_PREVIEW").is_none() { + return; + } + assert!( + env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("x86_64") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"), + "the functional browser presenter supports Windows x64 MSVC only" + ); + assert!( + env::var_os("CARGO_FEATURE_CEF_PREVIEW").is_none() + && env::var_os("CARGO_FEATURE_NATIVE_TEST_DRIVER").is_none(), + "the functional browser presenter cannot link CEF or native test bridges" + ); + let directory = PathBuf::from( + env::var_os("COLOSSUS_BROWSER_PRESENTER_NATIVE_LIB_DIR") + .expect("functional preview requires the reviewed native presenter library directory"), + ) + .canonicalize() + .expect("functional browser presenter library directory must exist"); + let archive = directory.join("colossus_browser_presenter.lib"); + assert!( + archive.is_file(), + "functional browser presenter archive is absent" + ); + println!("cargo:rerun-if-changed={}", archive.display()); + println!("cargo:rustc-link-search=native={}", directory.display()); + println!("cargo:rustc-link-lib=static=colossus_browser_presenter"); + for library in ["comctl32", "dwmapi", "gdi32", "imm32", "user32"] { + println!("cargo:rustc-link-lib={library}"); + } + println!("cargo:rustc-cfg=colossus_browser_presenter_linked"); +} diff --git a/apps/desktop/native-browser/src/contained.rs b/apps/desktop/native-browser/src/contained.rs index bd4250007..d0dc3f358 100644 --- a/apps/desktop/native-browser/src/contained.rs +++ b/apps/desktop/native-browser/src/contained.rs @@ -87,7 +87,10 @@ pub trait RemoteHostOwner: Send + Sync { /// Whether this build includes the native pixel compositor, independent of host acceptance. #[must_use] pub const fn native_presentation_available() -> bool { - cfg!(all(colossus_cef_linked, any(windows, target_os = "macos"))) + cfg!(all( + colossus_browser_presenter_linked, + any(windows, target_os = "macos") + )) } /// Attach independently admitted pixels through the platform compositor. @@ -102,13 +105,13 @@ pub async fn attach( owner: Arc, sink: EventSink, ) -> Result { - #[cfg(all(colossus_cef_linked, any(windows, target_os = "macos")))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] { crate::remote::RemoteSurface::create(window, client, configure, digest, owner, sink) .await .map(BrowserView::Remote) } - #[cfg(not(all(colossus_cef_linked, any(windows, target_os = "macos"))))] + #[cfg(not(all(colossus_browser_presenter_linked, any(windows, target_os = "macos"))))] { let _ = (window, client, configure, digest, sink); owner.close().await?; @@ -127,7 +130,7 @@ pub async fn attach_read_only( owner: Arc, sink: EventSink, ) -> Result { - #[cfg(all(colossus_cef_linked, any(windows, target_os = "macos")))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] { crate::remote::RemoteSurface::create_read_only( window, client, configure, digest, owner, sink, @@ -135,7 +138,7 @@ pub async fn attach_read_only( .await .map(BrowserView::Remote) } - #[cfg(not(all(colossus_cef_linked, any(windows, target_os = "macos"))))] + #[cfg(not(all(colossus_browser_presenter_linked, any(windows, target_os = "macos"))))] { let _ = (window, client, configure, digest, sink); owner.close().await?; diff --git a/apps/desktop/native-browser/src/lib.rs b/apps/desktop/native-browser/src/lib.rs index 18e05e65e..e0f6c7361 100644 --- a/apps/desktop/native-browser/src/lib.rs +++ b/apps/desktop/native-browser/src/lib.rs @@ -4,25 +4,31 @@ //! or host objects. Future agent control must enter through a separately authorized //! runtime adapter; possession of a guest handle is not an agent permit. +#[cfg(all( + feature = "windows-browser-functional-preview", + any( + not(windows), + not(target_arch = "x86_64"), + feature = "cef-preview", + feature = "native-test-driver" + ) +))] +compile_error!("functional browser presentation supports Windows x64 without CEF or test bridges"); + #[cfg(feature = "cef-preview")] pub mod chromium; -#[cfg(feature = "cef-preview")] +#[cfg(any( + feature = "cef-preview", + feature = "windows-browser-functional-preview" +))] pub mod contained; mod engine; mod navigation; #[cfg(feature = "cef-preview")] pub mod pki; -#[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") -))] +#[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] pub mod presentation; -#[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") -))] +#[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] pub mod remote; #[cfg(any(target_os = "macos", test))] mod response; diff --git a/apps/desktop/native-browser/src/presentation.rs b/apps/desktop/native-browser/src/presentation.rs index 9a36cda74..a75b20431 100644 --- a/apps/desktop/native-browser/src/presentation.rs +++ b/apps/desktop/native-browser/src/presentation.rs @@ -1,14 +1,26 @@ //! Trusted owned native pixel views for a separately contained offscreen host. //! This adapter does not grant browser authority or enable production automation. mod callbacks; +#[cfg(windows)] +mod completed; +#[cfg(windows)] +mod completed_cache; mod ffi; mod lifecycle; +#[cfg(windows)] +pub use completed::CompletedSurface; +#[cfg(windows)] +pub(crate) use completed_cache::CompletedCache; #[cfg(all(windows, feature = "native-test-driver"))] mod windows_acceptance; use crate::BrowserError; #[cfg(feature = "native-test-driver")] use colossus_browser_presentation::Frame; -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] use colossus_browser_presentation::PresentationError; use colossus_browser_presentation::{FrameCodec, Input, LatestFrames, Lease, LeaseGuard}; use std::{ @@ -30,6 +42,8 @@ struct Owner { focus: Box, frames: Mutex, scheduled: AtomicBool, + #[cfg(windows)] + completed: Mutex>>, #[cfg(feature = "native-test-driver")] accepted_frame: Mutex>, } @@ -117,6 +131,18 @@ pub struct PresentationSurface { codec: Mutex, } impl PresentationSurface { + #[cfg(windows)] + pub(crate) fn retain_completed_in( + &self, + cache: Arc, + ) -> Result<(), BrowserError> { + *self + .owner + .completed + .lock() + .map_err(|_| BrowserError::Closed)? = Some(cache); + Ok(()) + } /// Create a hidden native view from the trusted application window and enrollment. /// /// # Errors @@ -140,6 +166,8 @@ impl PresentationSurface { focus: Box::new(focus), frames: Mutex::new(frames), scheduled: AtomicBool::new(false), + #[cfg(windows)] + completed: Mutex::new(None), #[cfg(feature = "native-test-driver")] accepted_frame: Mutex::new(None), }); @@ -204,10 +232,14 @@ impl PresentationSurface { .map_err(|_| BrowserError::Closed)? .decode(envelope) .inspect_err(|error| { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] trace_frame_rejection("decode", *error); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -220,10 +252,14 @@ impl PresentationSurface { .map_err(|_| BrowserError::Closed)? .push(frame) .inspect_err(|error| { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] trace_frame_rejection("queue", *error); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -338,7 +374,11 @@ impl PresentationSurface { .map_err(|_| BrowserError::Closed) } } -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] fn trace_frame_rejection(stage: &'static str, error: PresentationError) { let failure = match error { PresentationError::Invalid => "invalid", @@ -347,8 +387,9 @@ fn trace_frame_rejection(stage: &'static str, error: PresentationError) { PresentationError::Hidden => "hidden", PresentationError::LimitExceeded => "limit_exceeded", PresentationError::OutcomeUnknown => "outcome_unknown", + PresentationError::Expired => "expired", }; - eprintln!("EVIDENCE mac-remote-frame stage={stage} failure={failure}"); + eprintln!("EVIDENCE native-remote-frame stage={stage} failure={failure}"); } #[cfg(windows)] fn parent(window: &Window) -> Option { @@ -456,7 +497,14 @@ fn schedule(window: &Window, owner: &Arc, handle: usize) -> Result<(), Br count, }); } - #[cfg(not(feature = "native-test-driver"))] + #[cfg(windows)] + if shown + && let Ok(cache) = owner.completed.lock() + && let Some(cache) = cache.as_ref() + { + cache.accepted(frame); + } + #[cfg(not(any(windows, feature = "native-test-driver")))] let _ = shown; } } else { diff --git a/apps/desktop/native-browser/src/presentation/completed.rs b/apps/desktop/native-browser/src/presentation/completed.rs new file mode 100644 index 000000000..ccae147a6 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/completed.rs @@ -0,0 +1,238 @@ +//! An inspectable last view, with no browser process, Core lease or input channel. +use super::{ + completed_cache::{CompletedCache, Reservation, RetainedFrame}, + ffi, parent, +}; +use crate::{BrowserError, PageState}; +use colossus_browser_presentation::Lease; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; +use tauri::{Rect, Window}; +use tokio::sync::oneshot; + +mod lifecycle; + +struct PendingHandle { + window: Window, + handle: usize, +} +impl Drop for PendingHandle { + fn drop(&mut self) { + let handle = self.handle; + if handle != 0 { + let _ = self.window.run_on_main_thread(move || { + // SAFETY: an unclaimed child is hidden and owns no pixel buffer. + let _ = unsafe { ffi::colossus_presenter_destroy(handle) }; + }); + } + } +} + +struct Pixels { + frame: Arc, + // The Windows child copies these bytes. Keep that allocation in the same + // process-wide budget until native destruction has been acknowledged. + _native_copy: Reservation, +} +#[derive(Default)] +struct State { + bounds: Option<[i32; 4]>, + visible: bool, + closed: bool, + epoch: u64, +} +struct Owner { + window: Window, + handle: AtomicUsize, + pixels: Mutex>>, + state: Mutex, + closing: tokio::sync::Mutex<()>, +} +/// Native static image retained after an agent browser finishes. +/// +/// Pixels never cross renderer IPC. This view cannot focus, navigate, or send +/// browser input. A separate 1500ms display watchdog hides it if the Desktop +/// stops confirming the selected, unobscured viewport. +#[derive(Clone)] +pub struct CompletedSurface(Arc); +impl CompletedSurface { + pub(crate) fn page(&self) -> Option { + self.0 + .pixels + .lock() + .ok()? + .as_ref() + .map(|pixels| pixels.frame.page.clone()) + } + pub(crate) async fn from_cache( + window: &Window, + cache: &CompletedCache, + lease: Lease, + ) -> Result, BrowserError> { + let Some(frame) = cache.take(lease) else { + return Ok(None); + }; + let Some(native_copy) = Reservation::acquire(frame.frame.pixels.len()) else { + return Ok(None); + }; + let native_window = window.clone(); + let (send, receive) = oneshot::channel(); + window + .run_on_main_thread(move || { + // SAFETY: parent is derived from our own retained Tauri window. + // The new child is hidden and has no owner/input/focus callbacks. + let handle = parent(&native_window).map_or(0, |parent| unsafe { + ffi::colossus_presenter_snapshot_create(parent) + }); + // A dropped/timed-out receiver also drops this cleanup guard. + let _ = send.send(PendingHandle { + window: native_window, + handle, + }); + }) + .map_err(|_| BrowserError::Closed)?; + let mut pending = tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + if pending.handle == 0 { + return Err(BrowserError::Unavailable); + } + let snapshot = Self(Arc::new(Owner { + window: window.clone(), + handle: AtomicUsize::new(pending.handle), + pixels: Mutex::new(Some(Arc::new(Pixels { + frame, + _native_copy: native_copy, + }))), + state: Mutex::new(State::default()), + closing: tokio::sync::Mutex::new(()), + })); + pending.handle = 0; + Ok(Some(snapshot)) + } + + /// Apply trusted Desktop viewport bounds without changing the retained image. + /// + /// # Errors + /// Rejects closed views, invalid scale, and unbounded native geometry. + pub fn set_bounds(&self, bounds: Rect) -> Result<(), BrowserError> { + let scale = self + .0 + .window + .scale_factor() + .map_err(|_| BrowserError::Closed)?; + if !scale.is_finite() || scale <= 0.0 { + return Err(BrowserError::Unavailable); + } + let position = bounds.position.to_physical::(scale); + let size = bounds.size.to_physical::(scale); + let values = [position.x, position.y, size.width, size.height]; + if values + .iter() + .any(|value| !value.is_finite() || !(0.0..=16384.0).contains(value)) + || size.width < 1.0 + || size.height < 1.0 + { + return Err(BrowserError::Unavailable); + } + #[allow(clippy::cast_possible_truncation)] + let bounds = values.map(|value| value.round() as i32); + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + if state.closed { + return Err(BrowserError::Closed); + } + state.bounds = Some(bounds); + Ok(()) + } + + /// Display only under a fresh local viewport pulse, independent of Core. + /// + /// # Errors + /// Rejects a closed snapshot, missing bounds or unavailable UI scheduling. + pub fn show(&self) -> Result<(), BrowserError> { + let epoch = { + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + if state.closed || state.bounds.is_none() { + return Err(BrowserError::Closed); + } + state.epoch = state.epoch.checked_add(1).ok_or(BrowserError::Closed)?; + state.visible = true; + state.epoch + }; + let until = Instant::now() + Duration::from_millis(1500); + let owner = Arc::clone(&self.0); + self.0 + .window + .run_on_main_thread(move || { + let bounds = owner.state.lock().ok().and_then(|state| { + (state.visible && !state.closed && state.epoch == epoch) + .then_some(state.bounds) + .flatten() + }); + let pixels = owner.pixels.lock().ok().and_then(|pixels| pixels.clone()); + let (Some([x, y, width, height]), Some(pixels)) = (bounds, pixels) else { + return; + }; + let ttl = + u32::try_from(until.saturating_duration_since(Instant::now()).as_millis()) + .unwrap_or(0); + let frame = &pixels.frame.frame; + // SAFETY: exact input-free child, validated geometry and immutable + // budgeted BGRA borrow through synchronous native copy. The epoch + // is local display lifetime, never a browser ownership generation. + let _ = unsafe { + ffi::colossus_presenter_snapshot_show( + owner.handle.load(Ordering::Acquire), + epoch, + ttl, + ffi::Bounds { + x, + y, + width, + height, + }, + frame.lease.pixel_width, + frame.lease.pixel_height, + frame.pixels.as_ptr(), + frame.pixels.len(), + ) + }; + }) + .map_err(|_| BrowserError::Closed) + } + + /// Hide immediately on the next native UI dispatch and revoke pending shows. + /// + /// # Errors + /// Reports poisoned state or unavailable UI scheduling. + pub fn hide(&self) -> Result<(), BrowserError> { + let epoch = { + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + state.visible = false; + state.epoch = state.epoch.checked_add(1).ok_or(BrowserError::Closed)?; + state.epoch + }; + let handle = self.0.handle.load(Ordering::Acquire); + let owner = Arc::clone(&self.0); + self.0 + .window + .run_on_main_thread(move || { + if !owner + .state + .lock() + .is_ok_and(|state| !state.visible && state.epoch == epoch) + { + return; + } + // SAFETY: hide only the exact static child; this cannot route input. + let _ = unsafe { ffi::colossus_presenter_visible(handle, 0) }; + }) + .map_err(|_| BrowserError::Closed) + } +} diff --git a/apps/desktop/native-browser/src/presentation/completed/lifecycle.rs b/apps/desktop/native-browser/src/presentation/completed/lifecycle.rs new file mode 100644 index 000000000..98076c206 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/completed/lifecycle.rs @@ -0,0 +1,74 @@ +use super::{CompletedSurface, Owner, Pixels, ffi}; +use crate::BrowserError; +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; +use tokio::sync::oneshot; + +impl CompletedSurface { + /// Destroy the static child and release retained pixels after native acknowledgment. + /// + /// # Errors + /// A missing UI acknowledgment retains the image and cleanup obligation. + pub async fn close(&self) -> Result<(), BrowserError> { + let _closing = self.0.closing.lock().await; + { + let mut state = self.0.state.lock().map_err(|_| BrowserError::Closed)?; + state.closed = true; + state.visible = false; + } + let handle = self.0.handle.load(Ordering::Acquire); + if handle == 0 { + return Ok(()); + } + let (send, receive) = oneshot::channel(); + self.0 + .window + .run_on_main_thread(move || { + // SAFETY: exact owned static child; no callbacks or browser host exist. + let _ = send.send(unsafe { ffi::colossus_presenter_destroy(handle) } == 0); + }) + .map_err(|_| BrowserError::Closed)?; + let closed = tokio::time::timeout(Duration::from_secs(2), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)?; + if !closed { + return Err(BrowserError::Closed); + } + self.0.handle.store(0, Ordering::Release); + self.0 + .pixels + .lock() + .map_err(|_| BrowserError::Closed)? + .take(); + Ok(()) + } +} + +impl Drop for Owner { + fn drop(&mut self) { + let handle = self.handle.swap(0, Ordering::AcqRel); + if handle == 0 { + return; + } + let pixels = self + .pixels + .get_mut() + .unwrap_or_else(|error| error.into_inner()) + .take(); + // Keep the image and both reservations charged if native cleanup cannot + // be acknowledged. Scheduling failure must not undercount native pixels. + let retained = pixels.map_or(0, |pixels| Arc::into_raw(pixels) as usize); + let _ = self.window.run_on_main_thread(move || { + // SAFETY: creation-thread destruction for this exact opaque identity. + if unsafe { ffi::colossus_presenter_destroy(handle) } == 0 && retained != 0 { + // SAFETY: balance the sole raw retain installed immediately above. + unsafe { + drop(Arc::from_raw(retained as *const Pixels)); + } + } + }); + } +} diff --git a/apps/desktop/native-browser/src/presentation/completed_cache.rs b/apps/desktop/native-browser/src/presentation/completed_cache.rs new file mode 100644 index 000000000..feeeab786 --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/completed_cache.rs @@ -0,0 +1,163 @@ +//! Bounded native custody of the last frame actually accepted by an agent viewer. +use crate::PageState; +use colossus_browser_presentation::{Frame, Lease}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; + +const MAX_RETAINED_BYTES: usize = 32 * 1024 * 1024; +static RETAINED_BYTES: AtomicUsize = AtomicUsize::new(0); + +/// Charges both cached pixels and a completed native child's separate paint copy. +pub(super) struct Reservation(usize); +impl Reservation { + pub(super) fn acquire(bytes: usize) -> Option { + if reserve(&RETAINED_BYTES, bytes, MAX_RETAINED_BYTES) { + Some(Self(bytes)) + } else { + None + } + } +} +impl Drop for Reservation { + fn drop(&mut self) { + RETAINED_BYTES.fetch_sub(self.0, Ordering::AcqRel); + } +} +fn reserve(counter: &AtomicUsize, bytes: usize, limit: usize) -> bool { + bytes != 0 + && counter + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current.checked_add(bytes).filter(|total| *total <= limit) + }) + .is_ok() +} + +pub(super) struct RetainedFrame { + pub(super) frame: Frame, + pub(super) page: PageState, + _reservation: Reservation, +} +#[derive(Default)] +struct State { + sealed: bool, + authority: Option<(u64, u64)>, + invalidated_through: u64, + frame: Option>, + page: Option<(Lease, PageState)>, +} +/// Shared across this Remote attachment's document/viewport surface replacements. +#[derive(Default)] +pub(crate) struct CompletedCache(Mutex); +impl CompletedCache { + /// Only trusted attach/handoff may establish a new native writer authority. + pub(crate) fn activate(&self, lease: Lease) { + if lease.control_generation == 0 { + return; + } + if let Ok(mut state) = self.0.lock() + && state.authority != Some(authority(lease)) + { + *state = State { + authority: Some(authority(lease)), + ..State::default() + }; + } + } + /// A known document transition cannot retain its prior image as a final candidate. + pub(crate) fn invalidate(&self, lease: Lease) { + if let Ok(mut state) = self.0.lock() + && state.authority == Some(authority(lease)) + { + state.invalidated_through = state.invalidated_through.max(lease.viewport_generation); + if state.frame.as_ref().is_some_and(|frame| { + frame.frame.lease.viewport_generation <= state.invalidated_through + }) { + state.frame.take(); + } + if state + .page + .as_ref() + .is_some_and(|(lease, _)| lease.viewport_generation <= state.invalidated_through) + { + state.page.take(); + } + } + } + /// Supplied only by an authenticated observation of this lease's target. + pub(crate) fn observed(&self, lease: Lease, page: PageState) { + if let Ok(mut state) = self.0.lock() + && !state.sealed + && state.authority == Some(authority(lease)) + && lease.viewport_generation > state.invalidated_through + && state.page.as_ref().is_none_or(|(current, _)| { + current.viewport_generation < lease.viewport_generation || *current == lease + }) + { + if state + .frame + .as_ref() + .is_some_and(|frame| frame.frame.lease != lease) + { + state.frame.take(); + } + state.page = Some((lease, page)); + } + } + /// Call only after authenticated decode AND synchronous native paint acceptance. + pub(super) fn accepted(&self, frame: Frame) { + let Ok(mut state) = self.0.lock() else { + return; + }; + if state.sealed + || state.authority != Some(authority(frame.lease)) + || frame.lease.viewport_generation <= state.invalidated_through + { + return; + } + let Some(page) = state + .page + .as_ref() + .filter(|(lease, _)| *lease == frame.lease) + .map(|(_, page)| page.clone()) + else { + return; + }; + // Release the previous candidate only after exact current metadata is + // found. A late old paint cannot remove a newer accepted document. + state.frame.take(); + let Some(reservation) = Reservation::acquire(frame.pixels.capacity()) else { + return; + }; + state.frame = Some(Arc::new(RetainedFrame { + frame, + page, + _reservation: reservation, + })); + } + /// One-way extraction: later frames cannot replace a completed tab's pixels. + pub(super) fn take(&self, lease: Lease) -> Option> { + let mut state = self.0.lock().ok()?; + state.sealed = true; + state + .frame + .take() + .filter(|frame| frame.frame.lease == lease) + } + pub(crate) fn clear(&self) { + if let Ok(mut state) = self.0.lock() { + state.sealed = true; + state.frame.take(); + state.page.take(); + } + } +} + +fn authority(lease: Lease) -> (u64, u64) { + (lease.session_generation, lease.control_generation) +} + +#[cfg(test)] +#[path = "completed_cache_tests.rs"] +mod tests; diff --git a/apps/desktop/native-browser/src/presentation/completed_cache_tests.rs b/apps/desktop/native-browser/src/presentation/completed_cache_tests.rs new file mode 100644 index 000000000..e9b21f96f --- /dev/null +++ b/apps/desktop/native-browser/src/presentation/completed_cache_tests.rs @@ -0,0 +1,170 @@ +use super::*; + +fn frame(viewport: u64) -> Frame { + Frame { + lease: Lease { + tab: 1, + session_generation: 1, + control_generation: 1, + viewport_generation: viewport, + document_generation: 1, + pixel_width: 1, + pixel_height: 1, + }, + sequence: 1, + stride: 4, + pixels: vec![0; 4], + } +} + +#[test] +fn budget_is_atomic_bounded_and_rejects_overflow() { + let bytes = AtomicUsize::new(0); + assert!(!reserve(&bytes, 0, 32)); + assert!(reserve(&bytes, 16, 32)); + assert!(reserve(&bytes, 16, 32)); + assert!(!reserve(&bytes, 1, 32)); + assert!(!reserve(&bytes, usize::MAX, 32)); + assert_eq!(bytes.load(Ordering::Acquire), 32); +} +fn accept(cache: &CompletedCache, viewport: u64) { + cache.activate(frame(viewport).lease); + cache.observed(frame(viewport).lease, PageState::default()); + cache.accepted(frame(viewport)); +} + +#[test] +fn completion_rejects_a_previous_document_or_viewport_and_seals_cache() { + let cache = CompletedCache::default(); + accept(&cache, 1); + assert!(cache.take(frame(2).lease).is_none()); + accept(&cache, 2); + assert!(cache.take(frame(2).lease).is_none()); + + let cache = CompletedCache::default(); + accept(&cache, 1); + let mut next_document = frame(1).lease; + next_document.document_generation += 1; + assert!(cache.take(next_document).is_none()); +} + +#[test] +fn extraction_moves_only_latest_accepted_frame_and_close_clears_it() { + let cache = CompletedCache::default(); + accept(&cache, 1); + accept(&cache, 2); + let accepted = cache.take(frame(2).lease).unwrap(); + assert_eq!(accepted.frame.lease.viewport_generation, 2); + assert!(cache.take(frame(2).lease).is_none()); + let cache = CompletedCache::default(); + accept(&cache, 1); + cache.clear(); + assert!(cache.take(frame(1).lease).is_none()); +} + +#[test] +fn frame_without_matching_authenticated_metadata_is_not_retained() { + let cache = CompletedCache::default(); + cache.activate(frame(1).lease); + cache.observed(frame(1).lease, PageState::default()); + cache.accepted(frame(2)); + assert!(cache.take(frame(2).lease).is_none()); +} + +#[test] +fn old_observations_and_paints_cannot_readmit_a_known_invalid_document() { + let cache = CompletedCache::default(); + accept(&cache, 1); + cache.invalidate(frame(1).lease); + accept(&cache, 1); + assert!(cache.take(frame(1).lease).is_none()); +} + +#[test] +fn late_old_results_cannot_clear_or_relabel_a_new_document() { + let cache = CompletedCache::default(); + accept(&cache, 1); + cache.invalidate(frame(1).lease); + accept(&cache, 2); + cache.observed( + frame(1).lease, + PageState { + title: "Stale".into(), + ..PageState::default() + }, + ); + cache.accepted(frame(1)); + cache.invalidate(frame(1).lease); + assert_eq!( + cache + .take(frame(2).lease) + .unwrap() + .frame + .lease + .viewport_generation, + 2 + ); +} + +#[test] +fn same_document_metadata_belongs_to_the_frame_that_was_actually_accepted() { + let cache = CompletedCache::default(); + let lease = frame(1).lease; + cache.activate(lease); + cache.observed( + lease, + PageState { + title: "First view".into(), + url: "https://example.com/first".into(), + ..PageState::default() + }, + ); + cache.accepted(frame(1)); + cache.observed( + lease, + PageState { + title: "Later title".into(), + url: "https://example.com/later".into(), + ..PageState::default() + }, + ); + let retained = cache.take(lease).unwrap(); + assert_eq!(retained.page.title, "First view"); + assert_eq!(retained.page.url, "https://example.com/first"); + let cache = CompletedCache::default(); + cache.activate(lease); + cache.observed( + lease, + PageState { + title: "Later title".into(), + ..PageState::default() + }, + ); + cache.accepted(frame(1)); + assert_eq!(cache.take(lease).unwrap().page.title, "Later title"); +} + +#[test] +fn new_authority_resets_epoch_fence_and_rejects_old_authority_results() { + let cache = CompletedCache::default(); + let old = frame(90); + cache.activate(old.lease); + cache.observed(old.lease, PageState::default()); + cache.invalidate(old.lease); + let mut current = frame(1); + current.lease.control_generation = 2; + cache.activate(current.lease); + cache.observed(current.lease, PageState::default()); + let expected = current.lease; + cache.accepted(current); + cache.invalidate(old.lease); + cache.observed( + old.lease, + PageState { + title: "Old authority".into(), + ..PageState::default() + }, + ); + cache.accepted(old); + assert_eq!(cache.take(expected).unwrap().frame.lease, expected); +} diff --git a/apps/desktop/native-browser/src/presentation/ffi.rs b/apps/desktop/native-browser/src/presentation/ffi.rs index 88329fdc4..4ba4a43c5 100644 --- a/apps/desktop/native-browser/src/presentation/ffi.rs +++ b/apps/desktop/native-browser/src/presentation/ffi.rs @@ -34,6 +34,19 @@ pub(super) struct WindowsScreen { pub center_bgra: [u8; 4], } unsafe extern "C" { + #[cfg(windows)] + pub(super) fn colossus_presenter_snapshot_create(parent: usize) -> usize; + #[cfg(windows)] + pub(super) fn colossus_presenter_snapshot_show( + view: usize, + epoch: u64, + display_ms: u32, + bounds: Bounds, + width: u32, + height: u32, + pixels: *const u8, + bytes: usize, + ) -> i32; pub(super) fn colossus_presenter_create( parent: usize, owner: *mut c_void, @@ -63,6 +76,8 @@ unsafe extern "C" { ) -> i32; #[cfg(all(windows, feature = "native-test-driver"))] pub(super) fn colossus_presenter_fixture_input(view: usize) -> i32; + #[cfg(all(windows, feature = "native-test-driver"))] + pub(super) fn colossus_presenter_fixture_keyboard_reentrancy(view: usize) -> i32; #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] pub(super) fn colossus_presenter_acceptance_activate_parent(parent: usize) -> i32; #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] diff --git a/apps/desktop/native-browser/src/presentation/windows_acceptance.rs b/apps/desktop/native-browser/src/presentation/windows_acceptance.rs index 8eae47ea5..2d9a618b9 100644 --- a/apps/desktop/native-browser/src/presentation/windows_acceptance.rs +++ b/apps/desktop/native-browser/src/presentation/windows_acceptance.rs @@ -34,8 +34,11 @@ impl PresentationSurface { let result = (|| { let mut probe = ffi::WindowsScreen::default(); // SAFETY: live opaque identity on its UI thread and correctly sized output. - if unsafe { ffi::colossus_presenter_screen_probe(handle, &raw mut probe) } != 0 - { + let status = unsafe { + ffi::colossus_presenter_screen_probe(handle, &raw mut probe) + }; + if status != 0 { + eprintln!("EVIDENCE Windows screen probe failed status={status}"); return Err(BrowserError::Unavailable); } let bytes = @@ -47,7 +50,7 @@ impl PresentationSurface { .ok_or(BrowserError::Unavailable)?; let mut pixels = vec![0; bytes]; // SAFETY: bounded owned output and exact dimensions just measured by Windows. - if unsafe { + let status = unsafe { ffi::colossus_presenter_screen_capture( handle, probe.width, @@ -55,8 +58,12 @@ impl PresentationSurface { pixels.as_mut_ptr(), pixels.len(), ) - } != 0 - { + }; + if status != 0 { + eprintln!( + "EVIDENCE Windows screen capture failed status={status} foreground={} visible={} hit_test={} width={} height={}", + probe.foreground, probe.visible, probe.hit_test, probe.width, probe.height + ); return Err(BrowserError::Unavailable); } Ok(WindowsScreenEvidence { @@ -96,4 +103,23 @@ impl PresentationSurface { .then_some(()) .ok_or(BrowserError::Unavailable) } + + pub(crate) async fn fixture_keyboard_reentrancy(&self) -> Result<(), BrowserError> { + let handle = self.handle.load(Ordering::Acquire); + let (send, receive) = oneshot::channel(); + self.window + .run_on_main_thread(move || { + // SAFETY: closed source-only Return sequence, deriving only this + // presenter's same-process foreground root on its owning UI thread. + let status = unsafe { ffi::colossus_presenter_fixture_keyboard_reentrancy(handle) }; + let _ = send.send(status == 0); + }) + .map_err(|_| BrowserError::Closed)?; + tokio::time::timeout(Duration::from_secs(3), receive) + .await + .map_err(|_| BrowserError::TimedOut)? + .map_err(|_| BrowserError::Closed)? + .then_some(()) + .ok_or(BrowserError::Unavailable) + } } diff --git a/apps/desktop/native-browser/src/remote.rs b/apps/desktop/native-browser/src/remote.rs index cba14f7b9..b2086f965 100644 --- a/apps/desktop/native-browser/src/remote.rs +++ b/apps/desktop/native-browser/src/remote.rs @@ -17,7 +17,11 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }; -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] use std::time::Duration; use std::time::Instant; use tauri::{Rect, Window}; @@ -62,19 +66,36 @@ struct Owner { updating: AtomicBool, read_only: AtomicBool, operation: tokio::sync::Mutex<()>, + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + diagnostic_failures: std::sync::atomic::AtomicU8, + #[cfg(windows)] + completed: Arc, } // Source-only, closed-vocabulary attach evidence. These enum variants contain // no URL, enrollment material, path, native pointer or channel payload. -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] enum AttachFailure { Configure(PresentationError), NativeView(BrowserError), } -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] impl AttachFailure { const fn token(self) -> &'static str { match self { + Self::Configure(PresentationError::Expired) => "configure_expired", Self::Configure(PresentationError::Invalid) => "configure_invalid", Self::Configure(PresentationError::Stale) => "configure_stale", Self::Configure(PresentationError::Unauthenticated) => "configure_unauthenticated", @@ -84,6 +105,7 @@ impl AttachFailure { Self::NativeView(BrowserError::InvalidAddress) => "native_view_invalid_address", Self::NativeView(BrowserError::Unavailable) => "native_view_unavailable", Self::NativeView(BrowserError::Closed) => "native_view_closed", + Self::NativeView(BrowserError::Expired) => "native_view_expired", Self::NativeView(BrowserError::Stale) => "native_view_stale", Self::NativeView(BrowserError::TimedOut) => "native_view_timed_out", Self::NativeView(BrowserError::ComponentMissing) => "native_view_component_missing", @@ -94,31 +116,93 @@ impl AttachFailure { } } } -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] fn trace_attach_failure(failure: AttachFailure) { - eprintln!("EVIDENCE mac-remote-attach failure={}", failure.token()); + eprintln!("EVIDENCE native-remote-attach failure={}", failure.token()); } -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] fn trace_frame_channel_failure(stage: &'static str, error: PresentationError, elapsed: Duration) { - let failure = match error { + let failure = presentation_failure_token(error); + let timing = if elapsed >= Duration::from_millis(700) { + "after_700ms" + } else { + "before_700ms" + }; + eprintln!("EVIDENCE native-remote-frame stage={stage} failure={failure} timing={timing}"); +} +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] +fn presentation_failure_token(error: PresentationError) -> &'static str { + match error { + PresentationError::Expired => "expired", PresentationError::Invalid => "invalid", PresentationError::Stale => "stale", PresentationError::Unauthenticated => "unauthenticated", PresentationError::Hidden => "hidden", PresentationError::LimitExceeded => "limit_exceeded", PresentationError::OutcomeUnknown => "outcome_unknown", + } +} +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] +fn trace_owner_failure( + owner: &Owner, + stage: &'static str, + failure: &'static str, + lease: Lease, + revision: u64, + elapsed: Duration, +) { + let bit = match stage { + "hide_host" => 1, + "viewport_renew_surface" => 2, + "viewport_renew_host" => 4, + _ => 8, }; - let timing = if elapsed >= Duration::from_millis(700) { - "after_700ms" + if owner.diagnostic_failures.fetch_or(bit, Ordering::AcqRel) & bit != 0 { + return; + } + let (current_lease, requested_visible, heartbeat_expired) = + owner.state.lock().map_or((false, false, false), |state| { + ( + state.lease == lease, + state.visible, + state.heartbeat.elapsed() >= Duration::from_millis(1500), + ) + }); + let revision_changed = owner.revision.load(Ordering::Acquire) != revision; + let role = if owner.read_only.load(Ordering::Acquire) { + "agent" } else { - "before_700ms" + "human" }; - eprintln!("EVIDENCE mac-remote-frame stage={stage} failure={failure} timing={timing}"); + eprintln!( + "EVIDENCE native-remote-owner stage={stage} failure={failure} role={role} current_lease={current_lease} requested_visible={requested_visible} revision_changed={revision_changed} heartbeat_expired={heartbeat_expired} elapsed_ms={}", + elapsed.as_millis() + ); } -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] fn trace_frame_browser_failure(stage: &'static str, error: BrowserError) { eprintln!( - "EVIDENCE mac-remote-frame stage={stage} failure={}", + "EVIDENCE native-remote-frame stage={stage} failure={}", AttachFailure::NativeView(error).token() ); } @@ -175,10 +259,10 @@ impl RemoteSurface { #[cfg(all(any(windows, target_os = "macos"), feature = "native-test-driver"))] pub async fn acceptance_probe(&self) -> Result { let lease = self.0.state.lock().map_err(|_| BrowserError::Closed)?.lease; - #[cfg(all(target_os = "macos", debug_assertions))] + #[cfg(all(any(windows, target_os = "macos"), debug_assertions))] let observed_at = Instant::now(); let page = self.0.client.observe(lease).await.map_err(|error| { - #[cfg(all(target_os = "macos", debug_assertions))] + #[cfg(all(any(windows, target_os = "macos"), debug_assertions))] trace_frame_channel_failure("probe_observe", error, observed_at.elapsed()); match error { PresentationError::Stale | PresentationError::Hidden => BrowserError::Stale, @@ -295,10 +379,14 @@ impl RemoteSurface { .configure(configure.clone()) .await .map_err(|failure| { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] trace_attach_failure(AttachFailure::Configure(failure)); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -306,18 +394,37 @@ impl RemoteSurface { BrowserError::Closed })?; let (events, receiver) = mpsc::channel(input_queue::CAPACITY); - let surface = actor::create_surface(window, &client, &events, lease, digest, !read_only) - .await - .inspect_err(|failure| { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] - trace_attach_failure(AttachFailure::NativeView(*failure)); - #[cfg(not(all( - target_os = "macos", - feature = "native-test-driver", - debug_assertions - )))] - let _ = failure; - })?; + #[cfg(windows)] + let completed = Arc::new(crate::presentation::CompletedCache::default()); + #[cfg(windows)] + if read_only { + completed.activate(lease); + } + let surface = actor::create_surface( + window, + &client, + &events, + lease, + digest, + !read_only, + #[cfg(windows)] + &completed, + ) + .await + .inspect_err(|failure| { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + trace_attach_failure(AttachFailure::NativeView(*failure)); + #[cfg(not(all( + target_os = "macos", + feature = "native-test-driver", + debug_assertions + )))] + let _ = failure; + })?; let viewport = configure.viewport_generation; let owner = Arc::new(Owner { window: window.clone(), @@ -342,6 +449,14 @@ impl RemoteSurface { updating: AtomicBool::new(false), read_only: AtomicBool::new(read_only), operation: tokio::sync::Mutex::new(()), + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + diagnostic_failures: std::sync::atomic::AtomicU8::new(0), + #[cfg(windows)] + completed, }); actor::start(&owner, receiver); pending.0.take(); @@ -469,6 +584,31 @@ impl RemoteSurface { !self.0.read_only.load(Ordering::Acquire) } + #[cfg(windows)] + pub(crate) async fn completed_snapshot( + &self, + ) -> Result, BrowserError> { + if !self.0.read_only.load(Ordering::Acquire) || self.0.closing.load(Ordering::Acquire) { + return Ok(None); + } + let lease = self.0.state.lock().map_err(|_| BrowserError::Closed)?.lease; + let snapshot = crate::presentation::CompletedSurface::from_cache( + &self.0.window, + &self.0.completed, + lease, + ) + .await?; + if self.0.closing.load(Ordering::Acquire) + || !self.0.state.lock().is_ok_and(|state| state.lease == lease) + { + if let Some(snapshot) = snapshot { + snapshot.close().await?; + } + return Ok(None); + } + Ok(snapshot) + } + /// Canonical conversation retained by this exact native runtime admission. #[must_use] pub fn conversation_id(&self) -> &str { diff --git a/apps/desktop/native-browser/src/remote/actor.rs b/apps/desktop/native-browser/src/remote/actor.rs index baa9a697c..3047f1cdb 100644 --- a/apps/desktop/native-browser/src/remote/actor.rs +++ b/apps/desktop/native-browser/src/remote/actor.rs @@ -15,12 +15,13 @@ pub(super) async fn create_surface( lease: Lease, digest: [u8; 32], human_input: bool, + #[cfg(windows)] completed: &Arc, ) -> Result { let inputs = events.clone(); let focuses = events.clone(); let input_client = client.clone(); let focus_client = client.clone(); - PresentationSurface::create( + let surface = PresentationSurface::create( window, lease, digest, @@ -47,7 +48,12 @@ pub(super) async fn create_surface( } }, ) - .await + .await?; + #[cfg(windows)] + if !human_input { + surface.retain_completed_in(Arc::clone(completed))?; + } + Ok(surface) } pub(super) fn start(owner: &Arc, events: mpsc::Receiver) { start_events(owner, events); @@ -64,8 +70,10 @@ fn start_events(owner: &Arc, mut events: mpsc::Receiver) { break; } if let Event::Human(command) = event { - if human(&owner, command).await.is_err() { - (owner.sink)(BrowserEvent::Failed); + match human(&owner, command).await { + Ok(()) => {} + Err(BrowserError::Expired) => expired(&owner), + Err(_) => (owner.sink)(BrowserEvent::Failed), } continue; } @@ -104,19 +112,23 @@ fn start_events(owner: &Arc, mut events: mpsc::Receiver) { }; match result { Ok(()) => {} + Err(colossus_browser_presentation::PresentationError::Expired) => { + expired(&owner); + break; + } Err( colossus_browser_presentation::PresentationError::Stale | colossus_browser_presentation::PresentationError::Hidden, ) => super::viewport::update(&owner), Err(error) => { #[cfg(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions ))] super::trace_frame_channel_failure(stage, error, started.elapsed()); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -172,13 +184,13 @@ fn start_frames(owner: &Arc) { } if let Err(error) = surface.present(frame) { #[cfg(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions ))] super::trace_frame_browser_failure("surface_present", error); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -188,6 +200,10 @@ fn start_frames(owner: &Arc) { } } Ok(None) => {} + Err(colossus_browser_presentation::PresentationError::Expired) => { + expired(&owner); + break; + } Err( colossus_browser_presentation::PresentationError::Stale | colossus_browser_presentation::PresentationError::Hidden, @@ -196,13 +212,13 @@ fn start_frames(owner: &Arc) { } Err(error) => { #[cfg(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions ))] super::trace_frame_channel_failure("poll", error, started.elapsed()); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -219,6 +235,11 @@ pub(super) fn fail(owner: &Arc) { owner.client.disconnect(); (owner.sink)(BrowserEvent::Crashed); } +pub(super) fn expired(owner: &Arc) { + let _ = lifecycle::hide(owner); + owner.client.disconnect(); + (owner.sink)(BrowserEvent::Expired); +} pub(super) async fn human(owner: &Arc, command: HumanCommand) -> Result<(), BrowserError> { let _operation = owner.operation.lock().await; if owner.closing.load(Ordering::Acquire) || owner.read_only.load(Ordering::Acquire) { @@ -238,7 +259,10 @@ pub(super) async fn human(owner: &Arc, command: HumanCommand) -> Result<( .client .command(PresentationCommand::Human { lease, command }) .await - .map_err(|_| BrowserError::Closed)?; + .map_err(|error| match error { + colossus_browser_presentation::PresentationError::Expired => BrowserError::Expired, + _ => BrowserError::Closed, + })?; (owner.sink)(BrowserEvent::Loading(true)); Ok(()) } @@ -248,12 +272,29 @@ pub(super) async fn inspect(owner: &Arc) -> Result BrowserError::Expired, + colossus_browser_presentation::PresentationError::Stale + | colossus_browser_presentation::PresentationError::Hidden => BrowserError::Stale, + _ => BrowserError::Closed, + })?; + let page = PageState { url: state.url, title: state.title, loading: state.loading, can_go_back: state.can_go_back, can_go_forward: state.can_go_forward, - }) + }; + #[cfg(windows)] + if owner.read_only.load(Ordering::Acquire) + && let Ok(current) = owner.state.lock() + && current.lease == lease + { + if current.configure.target == state.target { + owner.completed.observed(lease, page.clone()); + } else { + owner.completed.invalidate(lease); + } + } + Ok(page) } diff --git a/apps/desktop/native-browser/src/remote/handoff.rs b/apps/desktop/native-browser/src/remote/handoff.rs index a35f76615..067503e3d 100644 --- a/apps/desktop/native-browser/src/remote/handoff.rs +++ b/apps/desktop/native-browser/src/remote/handoff.rs @@ -33,6 +33,8 @@ pub(super) async fn transfer(owner: &Arc, run_id: &str) -> Result<(), Bro .configure(configure.clone()) .await .map_err(|_| BrowserError::Closed)?; + #[cfg(windows)] + owner.completed.activate(lease); let next = actor::create_surface( &owner.window, &owner.client, @@ -40,6 +42,8 @@ pub(super) async fn transfer(owner: &Arc, run_id: &str) -> Result<(), Bro lease, owner.digest, false, + #[cfg(windows)] + &owner.completed, ) .await?; owner diff --git a/apps/desktop/native-browser/src/remote/input_queue.rs b/apps/desktop/native-browser/src/remote/input_queue.rs index ade877ff2..d5bb30d59 100644 --- a/apps/desktop/native-browser/src/remote/input_queue.rs +++ b/apps/desktop/native-browser/src/remote/input_queue.rs @@ -50,16 +50,17 @@ mod tests { .chars() .enumerate() .flat_map(|(index, character)| { + let code = 65 + u16::try_from(index).expect("short input fixture"); [ Input::Key { - code: 65 + index as u16, + code, pressed: true, }, Input::ImeCommit { text: character.to_string(), }, Input::Key { - code: 65 + index as u16, + code, pressed: false, }, ] diff --git a/apps/desktop/native-browser/src/remote/lifecycle.rs b/apps/desktop/native-browser/src/remote/lifecycle.rs index 6c140a3df..f8a487184 100644 --- a/apps/desktop/native-browser/src/remote/lifecycle.rs +++ b/apps/desktop/native-browser/src/remote/lifecycle.rs @@ -34,6 +34,8 @@ pub(super) fn hide(owner: &Arc) -> Result<(), BrowserError> { } pub(super) async fn close(owner: &Arc) -> Result<(), BrowserError> { owner.closing.store(true, Ordering::Release); + #[cfg(windows)] + owner.completed.clear(); hide(owner)?; let _operation = owner.operation.lock().await; let (surface, lease) = { @@ -61,13 +63,43 @@ pub(super) async fn hide_host( owner: &Owner, lease: colossus_browser_presentation::Lease, ) -> Result<(), BrowserError> { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + let started = ( + std::time::Instant::now(), + owner.revision.load(Ordering::Acquire), + ); match owner.client.hide(lease).await { Ok(()) | Err( colossus_browser_presentation::PresentationError::Stale | colossus_browser_presentation::PresentationError::Hidden, ) => Ok(()), - Err(_) => Err(BrowserError::Closed), + Err(error) => { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + super::trace_owner_failure( + owner, + "hide_host", + super::presentation_failure_token(error), + lease, + started.1, + started.0.elapsed(), + ); + #[cfg(not(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + )))] + let _ = error; + Err(BrowserError::Closed) + } } } pub(super) async fn hide_surface( diff --git a/apps/desktop/native-browser/src/remote/viewport.rs b/apps/desktop/native-browser/src/remote/viewport.rs index bfe53ab0a..2c4fb853f 100644 --- a/apps/desktop/native-browser/src/remote/viewport.rs +++ b/apps/desktop/native-browser/src/remote/viewport.rs @@ -4,9 +4,15 @@ use super::{ lifecycle, }; use crate::BrowserError; -#[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] +#[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions +))] use std::time::Instant; use std::{ + future::Future, + sync::Mutex, sync::{Arc, atomic::Ordering}, time::Duration, }; @@ -22,16 +28,20 @@ pub(super) fn update(owner: &Arc) { applied = owner.revision.load(Ordering::Acquire); match apply(&owner, applied).await { Ok(()) => {} + Err(BrowserError::Expired) => { + super::actor::expired(&owner); + break; + } Err(BrowserError::Stale) => { if let Err(error) = wait_for_target(&owner).await { #[cfg(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions ))] super::trace_frame_browser_failure("viewport_target_wait", error); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -42,13 +52,13 @@ pub(super) fn update(owner: &Arc) { } Err(error) => { #[cfg(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions ))] super::trace_frame_browser_failure("viewport_apply", error); #[cfg(not(all( - target_os = "macos", + any(windows, target_os = "macos"), feature = "native-test-driver", debug_assertions )))] @@ -90,23 +100,33 @@ async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { let Some(bounds) = bounds else { return Ok(()); }; - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] let observed_at = Instant::now(); let state = owner.client.observe(lease).await.map_err(|error| { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] super::trace_frame_channel_failure("viewport_observe", error, observed_at.elapsed()); presentation_error(error) })?; - let ttl = remaining(owner, revision) - .inspect_err(|error| trace_apply("viewport_ttl_after_observe", *error))?; + #[cfg(windows)] + let page = observed_page(owner, lease, &configure, &state); + let ttl = remaining(owner, revision).inspect_err(|error| { + trace_remaining(owner, lease, revision, "viewport_ttl_after_observe", *error); + })?; if applied == Some(bounds) && state.target == configure.target && let Some(surface) = &surface - && surface - .renew_confirmed(lease, Duration::from_millis(u64::from(ttl))) + && renew_surface(owner, surface, lease, revision, ttl) .await .is_ok() - && owner.client.renew(lease, ttl).await.is_ok() + && renew_host(owner, lease, revision, ttl).await.is_ok() { return Ok(()); } @@ -126,8 +146,15 @@ async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { configure.width = bounds.width; configure.height = bounds.height; configure.scale_milli = bounds.scale_milli; - configure.lease_ms = remaining(owner, revision) - .inspect_err(|error| trace_apply("viewport_ttl_before_configure", *error))?; + configure.lease_ms = remaining(owner, revision).inspect_err(|error| { + trace_remaining( + owner, + lease, + revision, + "viewport_ttl_before_configure", + *error, + ); + })?; configure.viewport_generation = owner .viewport .fetch_add(1, Ordering::AcqRel) @@ -139,6 +166,85 @@ async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { .await .map_err(presentation_error) .inspect_err(|error| trace_apply("viewport_configure", *error))?; + let next = initialize_configured(&owner.state, configure, lease, async { + #[cfg(windows)] + if owner.read_only.load(Ordering::Acquire) { + owner.completed.observed(lease, page); + } + create_replacement(owner, revision, lease, bounds).await + }) + .await?; + let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; + state.applied = (owner.revision.load(Ordering::Acquire) == revision).then_some(bounds); + state.surface = Some(Arc::new(next)); + Ok(()) +} + +#[cfg(windows)] +fn observed_page( + owner: &Owner, + lease: colossus_browser_presentation::Lease, + configure: &colossus_browser_presentation::Configure, + state: &colossus_browser_presentation::PageState, +) -> crate::PageState { + let page = crate::PageState { + url: state.url.clone(), + title: state.title.clone(), + loading: state.loading, + can_go_back: state.can_go_back, + can_go_forward: state.can_go_forward, + }; + if state.target != configure.target { + owner.completed.invalidate(lease); + } else if owner.read_only.load(Ordering::Acquire) { + owner.completed.observed(lease, page.clone()); + } + page +} + +/// Retain authenticated native ownership before any fallible UI initialization. +/// The old, already-hidden surface stays owned until a replacement is installed. +async fn initialize_configured( + state: &Mutex, + configure: colossus_browser_presentation::Configure, + lease: colossus_browser_presentation::Lease, + initialize: impl Future>, +) -> Result { + configure.validate().map_err(|_| BrowserError::Closed)?; + lease.validate().map_err(|_| BrowserError::Closed)?; + { + let mut state = state.lock().map_err(|_| BrowserError::Closed)?; + let same_tab = state.configure.target.tab_id == configure.target.tab_id; + if !configure.accepts(lease) + || configure.session != state.configure.session + || configure.control_generation != state.configure.control_generation + || lease.session_generation != state.lease.session_generation + || lease.viewport_generation <= state.lease.viewport_generation + || same_tab + && (lease.tab != state.lease.tab + || lease.document_generation < state.lease.document_generation) + { + return Err(BrowserError::Closed); + } + state.configure = configure; + state.lease = lease; + state.applied = None; + } + initialize.await +} + +#[cfg(test)] +#[path = "viewport_tests.rs"] +mod tests; + +/// Initialize the replacement under the caller's viewport operation lock. +/// All awaits preserve the original trusted heartbeat deadline. +async fn create_replacement( + owner: &Arc, + revision: u64, + lease: colossus_browser_presentation::Lease, + bounds: super::Bounds, +) -> Result { let next = create_surface( &owner.window, &owner.client, @@ -146,13 +252,15 @@ async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { lease, owner.digest, !owner.read_only.load(Ordering::Acquire), + #[cfg(windows)] + &owner.completed, ) .await .inspect_err(|error| trace_apply("viewport_create_surface", *error))?; let ttl = match remaining(owner, revision) { Ok(ttl) => ttl, Err(error) => { - trace_apply("viewport_ttl_after_create", error); + trace_remaining(owner, lease, revision, "viewport_ttl_after_create", error); next.hide_confirmed().await?; lifecycle::hide_host(owner, lease).await?; return Err(error); @@ -167,12 +275,7 @@ async fn apply(owner: &Arc, revision: u64) -> Result<(), BrowserError> { next.hide_confirmed().await?; lifecycle::hide_host(owner, lease).await?; } - let mut state = owner.state.lock().map_err(|_| BrowserError::Closed)?; - state.configure = configure; - state.lease = lease; - state.applied = (owner.revision.load(Ordering::Acquire) == revision).then_some(bounds); - state.surface = Some(Arc::new(next)); - Ok(()) + Ok(next) } fn set_native_bounds( surface: &crate::presentation::PresentationSurface, @@ -192,18 +295,126 @@ fn set_native_bounds( ) } fn trace_apply(stage: &'static str, error: BrowserError) { - #[cfg(all(target_os = "macos", feature = "native-test-driver", debug_assertions))] + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] super::trace_frame_browser_failure(stage, error); - #[cfg(not(all(target_os = "macos", feature = "native-test-driver", debug_assertions)))] + #[cfg(not(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + )))] let _ = (stage, error); } fn presentation_error(error: colossus_browser_presentation::PresentationError) -> BrowserError { match error { + colossus_browser_presentation::PresentationError::Expired => BrowserError::Expired, colossus_browser_presentation::PresentationError::Stale | colossus_browser_presentation::PresentationError::Hidden => BrowserError::Stale, _ => BrowserError::Closed, } } +async fn renew_surface( + owner: &Owner, + surface: &crate::presentation::PresentationSurface, + lease: colossus_browser_presentation::Lease, + revision: u64, + ttl: u16, +) -> Result<(), BrowserError> { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + let started = Instant::now(); + surface + .renew_confirmed(lease, Duration::from_millis(u64::from(ttl))) + .await + .inspect_err(|error| { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + super::trace_owner_failure( + owner, + "viewport_renew_surface", + super::AttachFailure::NativeView(*error).token(), + lease, + revision, + started.elapsed(), + ); + #[cfg(not(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + )))] + let _ = (owner, revision, error); + }) +} +async fn renew_host( + owner: &Owner, + lease: colossus_browser_presentation::Lease, + revision: u64, + ttl: u16, +) -> Result<(), colossus_browser_presentation::PresentationError> { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + let started = Instant::now(); + owner.client.renew(lease, ttl).await.inspect_err(|error| { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + super::trace_owner_failure( + owner, + "viewport_renew_host", + super::presentation_failure_token(*error), + lease, + revision, + started.elapsed(), + ); + #[cfg(not(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + )))] + let _ = (revision, error); + }) +} +fn trace_remaining( + owner: &Owner, + lease: colossus_browser_presentation::Lease, + revision: u64, + stage: &'static str, + error: BrowserError, +) { + #[cfg(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + ))] + super::trace_owner_failure( + owner, + stage, + super::AttachFailure::NativeView(error).token(), + lease, + revision, + Duration::ZERO, + ); + #[cfg(not(all( + any(windows, target_os = "macos"), + feature = "native-test-driver", + debug_assertions + )))] + let _ = (owner, lease, revision, stage, error); +} async fn wait_for_target(owner: &Owner) -> Result<(), BrowserError> { let _operation = owner.operation.lock().await; let (surface, lease) = { diff --git a/apps/desktop/native-browser/src/remote/viewport_tests.rs b/apps/desktop/native-browser/src/remote/viewport_tests.rs new file mode 100644 index 000000000..e682048af --- /dev/null +++ b/apps/desktop/native-browser/src/remote/viewport_tests.rs @@ -0,0 +1,126 @@ +use super::initialize_configured; +use crate::{ + BrowserError, + remote::{Bounds, State}, +}; +use colossus_browser_presentation::{Configure, Lease}; +use serde_json::json; +use std::{ + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Instant, +}; + +fn configure(viewport: u64) -> Configure { + serde_json::from_value(json!({ + "session":"bs_11111111111111111111111111111111", + "target":{"tab_id":"bt_11111111111111111111111111111111", + "document_id":"bd_11111111111111111111111111111111"}, + "control_generation":1,"viewport_generation":viewport, + "width":800,"height":600,"scale_milli":1000,"lease_ms":1500 + })) + .unwrap() +} +fn lease(viewport: u64) -> Lease { + Lease { + tab: 1, + session_generation: 1, + control_generation: 1, + viewport_generation: viewport, + document_generation: 1, + pixel_width: 800, + pixel_height: 600, + } +} +fn state() -> Mutex { + let bounds = Some(Bounds { + x: 0, + y: 0, + width: 800, + height: 600, + scale_milli: 1000, + }); + Mutex::new(State { + configure: configure(1), + lease: lease(1), + bounds, + applied: bounds, + visible: true, + heartbeat: Instant::now(), + surface: None, + }) +} + +#[tokio::test] +async fn acknowledged_lease_survives_stale_initialization_and_allows_a_fresh_retry() { + let state = state(); + let result: Result<(), _> = initialize_configured(&state, configure(2), lease(2), async { + let current = state.lock().unwrap(); + assert_eq!(current.lease, lease(2)); + assert_eq!(current.configure, configure(2)); + assert!(current.applied.is_none()); + Err(BrowserError::Stale) + }) + .await; + assert_eq!(result, Err(BrowserError::Stale)); + assert_eq!(state.lock().unwrap().lease, lease(2)); + initialize_configured(&state, configure(3), lease(3), async { + assert_eq!(state.lock().unwrap().lease, lease(3)); + Ok(()) + }) + .await + .unwrap(); + let current = state.lock().unwrap(); + assert_eq!(current.configure, configure(3)); + assert_eq!(current.lease, lease(3)); + assert!(current.visible && current.bounds.is_some() && current.applied.is_none()); +} + +#[tokio::test] +async fn wrong_or_replayed_ack_cannot_advance_ownership_or_poll_the_initializer() { + for violation in 0..6 { + let state = state(); + let mut config = configure(2); + let mut native = lease(2); + match violation { + 0 => native.viewport_generation = 1, + 1 => { + config.session = + serde_json::from_value(json!("bs_22222222222222222222222222222222")).unwrap(); + } + 2 => { + config.control_generation = 2; + native.control_generation = 2; + } + 3 => native.session_generation = 2, + 4 => native.tab = 2, + _ => native.pixel_width = 801, + } + let polled = AtomicBool::new(false); + let result = initialize_configured(&state, config, native, async { + polled.store(true, Ordering::Release); + Ok(()) + }) + .await; + assert_eq!(result, Err(BrowserError::Closed)); + assert!(!polled.load(Ordering::Acquire)); + assert_eq!(state.lock().unwrap().lease, lease(1)); + assert_eq!(state.lock().unwrap().configure, configure(1)); + } +} + +#[tokio::test] +async fn ui_failure_keeps_the_acknowledged_lease_without_restoring_visibility() { + let state = state(); + let result: Result<(), _> = initialize_configured(&state, configure(2), lease(2), async { + state.lock().unwrap().visible = false; + Err(BrowserError::Closed) + }) + .await; + assert_eq!(result, Err(BrowserError::Closed)); + let current = state.lock().unwrap(); + assert_eq!(current.lease, lease(2)); + assert!(current.applied.is_none() && !current.visible); +} diff --git a/apps/desktop/native-browser/src/remote/windows_acceptance.rs b/apps/desktop/native-browser/src/remote/windows_acceptance.rs index 6d01470ce..fcedb8c4f 100644 --- a/apps/desktop/native-browser/src/remote/windows_acceptance.rs +++ b/apps/desktop/native-browser/src/remote/windows_acceptance.rs @@ -37,4 +37,25 @@ impl RemoteSurface { .ok_or(BrowserError::Unavailable)?; surface.fixture_input().await } + + /// Exercise reentrant Return messages in this presenter's own Tao parent. + /// + /// # Errors + /// Requires human control, the exact foreground parent, an observed nested + /// callback and a subsequent main-thread acknowledgement. The external native + /// acceptance runner must bound its disposable process if Tao self-deadlocks. + pub async fn acceptance_windows_keyboard_reentrancy(&self) -> Result<(), BrowserError> { + if !self.human_control_available() { + return Err(BrowserError::Unavailable); + } + let surface = self + .0 + .state + .lock() + .map_err(|_| BrowserError::Closed)? + .surface + .clone() + .ok_or(BrowserError::Unavailable)?; + surface.fixture_keyboard_reentrancy().await + } } diff --git a/apps/desktop/native-browser/src/types.rs b/apps/desktop/native-browser/src/types.rs index d65f7572c..f4f7440ec 100644 --- a/apps/desktop/native-browser/src/types.rs +++ b/apps/desktop/native-browser/src/types.rs @@ -29,6 +29,8 @@ pub enum BrowserEvent { Loading(bool), Failed, Crashed, + /// The supervised context reached its original fixed authority deadline. + Expired, Blocked, Download, Popup(String), @@ -47,6 +49,8 @@ pub enum BrowserError { InvalidAddress, Unavailable, Closed, + /// The original supervised network/session authority expired. + Expired, /// The native owner is changing the document; hide pixels until a fresh target is admitted. Stale, TimedOut, @@ -61,6 +65,7 @@ impl std::fmt::Display for BrowserError { Self::InvalidAddress => "Enter an HTTP or HTTPS address without embedded credentials.", Self::Unavailable => "The embedded browser is unavailable on this device.", Self::Closed => "This browser tab is no longer available.", + Self::Expired => "This browser session expired. Continue in a new session to browse again.", Self::Stale => "The browser page is changing. Waiting for its current document.", Self::TimedOut => "The browser did not respond. Close the tab and try again.", Self::ComponentMissing => "The verified Chromium component is not included in this build.", diff --git a/apps/desktop/native-browser/src/view.rs b/apps/desktop/native-browser/src/view.rs index cd2966246..93a38e849 100644 --- a/apps/desktop/native-browser/src/view.rs +++ b/apps/desktop/native-browser/src/view.rs @@ -5,33 +5,59 @@ use url::Url; use crate::{BrowserError, NavigationAction, PageState}; -/// One actual native guest. Chromium never navigates a second `WebKit` page. +/// A native guest or inert address-entry tab. Chromium never navigates a second `WebKit` page. #[derive(Clone)] #[allow(clippy::large_enum_variant)] // Unlinked preview surface is an inert zero-sized stub. pub enum BrowserView { + /// Local address-entry placeholder. It owns no guest, network, or input authority. + Empty, /// Existing human browsing adapter retained until native Chromium acceptance. System(Webview), /// Explicitly enabled embedded Chromium preview. #[cfg(feature = "cef-preview")] Chromium(crate::chromium::Surface), /// Trusted attachment to a separately contained Chromium host. - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Remote(crate::remote::RemoteSurface), + /// Completed agent image, with no live browser, navigation or input authority. + #[cfg(all(windows, colossus_browser_presenter_linked))] + Snapshot(crate::presentation::CompletedSurface), } impl BrowserView { + /// Retain an input-free last view before positively closing an agent context. + /// Canonical run completion must be verified by the Desktop owner first. + /// + /// # Errors + /// Reports native child creation failure; missing or stale frames return None. + #[cfg_attr( + not(all(windows, colossus_browser_presenter_linked)), + allow(clippy::unused_async) + )] + pub async fn completed_snapshot(&self) -> Result, BrowserError> { + #[cfg(all(windows, colossus_browser_presenter_linked))] + if let Self::Remote(view) = self { + return view + .completed_snapshot() + .await + .map(|snapshot| snapshot.map(Self::Snapshot)); + } + Ok(None) + } + + /// Bounded native metadata belonging to this completed image's accepted frame. + #[must_use] + pub fn completed_page(&self) -> Option { + #[cfg(all(windows, colossus_browser_presenter_linked))] + if let Self::Snapshot(view) = self { + return view.page(); + } + None + } /// Exact canonical conversation of an independently admitted browser context. #[must_use] pub fn conversation_id(&self) -> Option<&str> { - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] if let Self::Remote(view) = self { return Some(view.conversation_id()); } @@ -41,11 +67,7 @@ impl BrowserView { /// Exact independently contained runtime session, when admitted. #[must_use] pub fn contained_session_id(&self) -> Option<&str> { - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] if let Self::Remote(view) = self { return Some(view.session_id()); } @@ -57,19 +79,11 @@ impl BrowserView { /// # Errors /// UI preview pages and unverified runtime contexts cannot grant automation. #[cfg_attr( - not(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - )), + not(all(colossus_browser_presenter_linked, any(windows, target_os = "macos"))), allow(clippy::unused_async) )] pub async fn handoff(&self, run_id: &str) -> Result<(), BrowserError> { - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] if let Self::Remote(view) = self { return view.handoff(run_id).await; } @@ -80,11 +94,11 @@ impl BrowserView { /// Whether the native attachment admits human navigation and keyboard input. #[must_use] pub fn human_control_available(&self) -> bool { - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(windows, colossus_browser_presenter_linked))] + if matches!(self, Self::Snapshot(_)) { + return false; + } + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] if let Self::Remote(view) = self { return view.human_control_available(); } @@ -104,14 +118,13 @@ impl BrowserView { /// Fails when the guest has closed or navigation is invalid. pub fn navigate(&self, url: Url) -> Result<(), BrowserError> { match self { + Self::Empty => Err(BrowserError::Unavailable), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(_) => Err(BrowserError::Unavailable), Self::System(view) => view.navigate(url).map_err(|_| BrowserError::Closed), #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.navigate(&url), - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.navigate(&url), } } @@ -122,14 +135,13 @@ impl BrowserView { /// Returns an error when the guest no longer exists. pub fn hide(&self) -> Result<(), BrowserError> { match self { + Self::Empty => Ok(()), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(view) => view.hide(), Self::System(view) => view.hide().map_err(|_| BrowserError::Closed), #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.hide(), - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.hide(), } } @@ -140,14 +152,13 @@ impl BrowserView { /// Returns an error when the guest no longer exists. pub fn show(&self) -> Result<(), BrowserError> { match self { + Self::Empty => Ok(()), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(view) => view.show(), Self::System(view) => view.show().map_err(|_| BrowserError::Closed), #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.show(), - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.show(), } } @@ -158,14 +169,13 @@ impl BrowserView { /// Returns an error when the guest no longer exists or its bounds are invalid. pub fn set_bounds(&self, bounds: Rect) -> Result<(), BrowserError> { match self { + Self::Empty => Ok(()), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(view) => view.set_bounds(bounds), Self::System(view) => view.set_bounds(bounds).map_err(|_| BrowserError::Closed), #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.set_bounds(bounds), - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.set_bounds(bounds), } } @@ -176,17 +186,16 @@ impl BrowserView { /// Returns an error when native teardown cannot complete. pub async fn close(&self) -> Result<(), BrowserError> { match self { + Self::Empty => Ok(()), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(view) => view.close().await, Self::System(view) => { let _ = crate::release(view).await; view.close().map_err(|_| BrowserError::Closed) } #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.close().await, - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.close().await, } } @@ -197,14 +206,13 @@ impl BrowserView { /// Fails if the guest is unavailable or its observation deadline expires. pub async fn inspect(&self) -> Result { match self { + Self::Empty => Ok(PageState::default()), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(_) => Err(BrowserError::Unavailable), Self::System(view) => crate::inspect(view).await, #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.inspect().await, - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.inspect().await, } } @@ -215,14 +223,13 @@ impl BrowserView { /// Fails if the guest is unavailable or its operation deadline expires. pub async fn control(&self, action: NavigationAction) -> Result<(), BrowserError> { match self { + Self::Empty => Err(BrowserError::Unavailable), + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(_) => Err(BrowserError::Unavailable), Self::System(view) => crate::control(view, action).await, #[cfg(feature = "cef-preview")] Self::Chromium(view) => view.control(action).await, - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(view) => view.control(action).await, } } @@ -231,14 +238,13 @@ impl BrowserView { #[must_use] pub fn system_view(&self) -> Option<&Webview> { match self { + Self::Empty => None, + #[cfg(all(windows, colossus_browser_presenter_linked))] + Self::Snapshot(_) => None, Self::System(view) => Some(view), #[cfg(feature = "cef-preview")] Self::Chromium(_) => None, - #[cfg(all( - feature = "cef-preview", - colossus_cef_linked, - any(windows, target_os = "macos") - ))] + #[cfg(all(colossus_browser_presenter_linked, any(windows, target_os = "macos")))] Self::Remote(_) => None, } } diff --git a/apps/desktop/native-credential-ui/Cargo.toml b/apps/desktop/native-credential-ui/Cargo.toml index 8f851376b..98ba18794 100644 --- a/apps/desktop/native-credential-ui/Cargo.toml +++ b/apps/desktop/native-credential-ui/Cargo.toml @@ -28,7 +28,7 @@ required-features = ["native-test-driver"] [dependencies] colossus-contracts = { version = "=0.11.8", path = "../../../crates/colossus-contracts" } -tauri = { version = "=2.11.5", features = [] } +tauri = { version = "=2.12.4", features = [] } tokio = { version = "1.49.0", features = ["sync"] } zeroize = "1.8.2" diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 5bb1e5683..285d890ba 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -14,7 +14,7 @@ "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@tabler/icons-react": "3.45.0", - "@tauri-apps/api": "2.11.1", + "@tauri-apps/api": "2.12.4", "@xterm/xterm": "6.0.0", "@xyflow/react": "12.11.3", "react": "19.2.7", @@ -27,7 +27,7 @@ "@axe-core/playwright": "4.12.1", "@playwright/test": "1.62.0", "@tailwindcss/vite": "4.3.3", - "@tauri-apps/cli": "2.11.4", + "@tauri-apps/cli": "2.12.3", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -2452,9 +2452,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz", + "integrity": "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -2462,9 +2462,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.3.tgz", + "integrity": "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -2478,23 +2478,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + "@tauri-apps/cli-darwin-arm64": "2.12.3", + "@tauri-apps/cli-darwin-x64": "2.12.3", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", + "@tauri-apps/cli-linux-arm64-musl": "2.12.3", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", + "@tauri-apps/cli-linux-x64-gnu": "2.12.3", + "@tauri-apps/cli-linux-x64-musl": "2.12.3", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", + "@tauri-apps/cli-win32-x64-msvc": "2.12.3" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.3.tgz", + "integrity": "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==", "cpu": [ "arm64" ], @@ -2509,9 +2509,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.3.tgz", + "integrity": "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==", "cpu": [ "x64" ], @@ -2526,9 +2526,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.3.tgz", + "integrity": "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==", "cpu": [ "arm" ], @@ -2543,9 +2543,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.3.tgz", + "integrity": "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==", "cpu": [ "arm64" ], @@ -2560,9 +2560,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.3.tgz", + "integrity": "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==", "cpu": [ "arm64" ], @@ -2577,9 +2577,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.3.tgz", + "integrity": "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==", "cpu": [ "riscv64" ], @@ -2594,9 +2594,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.3.tgz", + "integrity": "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==", "cpu": [ "x64" ], @@ -2611,9 +2611,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.3.tgz", + "integrity": "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==", "cpu": [ "x64" ], @@ -2628,9 +2628,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.3.tgz", + "integrity": "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==", "cpu": [ "arm64" ], @@ -2645,9 +2645,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.3.tgz", + "integrity": "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==", "cpu": [ "ia32" ], @@ -2662,9 +2662,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.3.tgz", + "integrity": "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1322dbdc8..bbb132ec3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -42,7 +42,7 @@ "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@tabler/icons-react": "3.45.0", - "@tauri-apps/api": "2.11.1", + "@tauri-apps/api": "2.12.4", "@xterm/xterm": "6.0.0", "@xyflow/react": "12.11.3", "react": "19.2.7", @@ -55,7 +55,7 @@ "@axe-core/playwright": "4.12.1", "@playwright/test": "1.62.0", "@tailwindcss/vite": "4.3.3", - "@tauri-apps/cli": "2.11.4", + "@tauri-apps/cli": "2.12.3", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index fd0e2b8dd..19172ba9f 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -54,15 +54,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -221,126 +221,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.91" @@ -472,6 +352,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -564,19 +450,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "borrow-or-share" version = "0.2.4" @@ -585,9 +458,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "brotli" -version = "8.0.4" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -596,9 +469,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -721,12 +594,13 @@ dependencies = [ [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] @@ -776,6 +650,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -848,7 +733,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1084,6 +969,7 @@ dependencies = [ "colossus-contracts", "colossus-home", "colossus-ports", + "dbus-secret-service", "fs4", "getrandom 0.4.3", "hex", @@ -1094,7 +980,6 @@ dependencies = [ "sha2", "subtle", "windows-native-keyring-store", - "zbus-secret-service-keyring-store", "zeroize", ] @@ -1139,10 +1024,10 @@ dependencies = [ "git2", "hex", "image", - "keyring", "keyring-core", "minisign-verify", "nix 0.31.3", + "notify-rust", "portable-pty", "rcgen", "redb", @@ -1156,8 +1041,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-notification", - "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", @@ -1231,12 +1114,12 @@ dependencies = [ "base64 0.22.1", "chacha20poly1305", "colossus-contracts", + "colossus-credentials", "colossus-ports", "ed25519-dalek", "fs4", "getrandom 0.4.3", "hex", - "keyring", "redb", "serde", "serde_json", @@ -1244,6 +1127,7 @@ dependencies = [ "tempfile", "time", "uuid", + "zeroize", ] [[package]] @@ -1292,7 +1176,7 @@ dependencies = [ "tokio", "url", "webview2-com", - "windows 0.61.3", + "windows", "windows-sys 0.61.2", "x509-parser", "zeroize", @@ -1425,6 +1309,7 @@ dependencies = [ "colossus-api-proto", "colossus-browser-presentation", "colossus-contracts", + "colossus-credentials", "colossus-darwin-process", "colossus-grpc", "colossus-sidecar-protocol", @@ -1432,7 +1317,6 @@ dependencies = [ "futures", "hex", "image", - "keyring", "nix 0.31.3", "prost", "prost-types", @@ -1505,15 +1389,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1555,7 +1430,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1610,8 +1485,8 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "web-sys", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -1693,9 +1568,9 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -1706,29 +1581,19 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "curve25519-dalek" @@ -1814,6 +1679,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "futures-util", + "hkdf", + "num", + "once_cell", + "openssl", + "rand 0.8.7", + "sha2", +] + [[package]] name = "defmt" version = "1.1.1" @@ -1943,6 +1827,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -2003,9 +1896,9 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", @@ -2046,21 +1939,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2185,33 +2063,6 @@ dependencies = [ "encoding_rs", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -2239,26 +2090,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fancy-regex" version = "0.16.2" @@ -2390,6 +2221,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2397,7 +2237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2411,6 +2251,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2950,12 +2796,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -2988,9 +2828,9 @@ checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -3128,7 +2968,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -3314,7 +3154,16 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" dependencies = [ - "cfb", + "cfb 0.7.3", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb 0.14.0", ] [[package]] @@ -3399,7 +3248,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3458,7 +3307,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.19", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3525,21 +3374,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.19", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -3574,25 +3423,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ "bitflags 2.13.1", "serde", - "unicode-segmentation", -] - -[[package]] -name = "keyring" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", ] [[package]] @@ -3646,6 +3482,7 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" dependencies = [ + "cc", "pkg-config", ] @@ -3678,7 +3515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3757,9 +3594,9 @@ checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -3838,9 +3675,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.3" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -3948,9 +3785,7 @@ dependencies = [ "futures-lite", "log", "mac-notification-sys", - "serde", "tauri-winrt-notification", - "zbus", ] [[package]] @@ -4421,29 +4256,76 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "openssl" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] [[package]] -name = "opentelemetry" -version = "0.32.0" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.19", - "tracing", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "opentelemetry_sdk" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.19", + "tracing", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ "futures-channel", @@ -4464,16 +4346,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "osakit" version = "0.3.1" @@ -4557,7 +4429,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4675,17 +4547,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -4741,20 +4602,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -5158,13 +5005,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -5179,6 +5037,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -5723,25 +5591,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secret-service" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", - "num", - "once_cell", - "serde", - "sha2", - "zbus", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -5767,9 +5616,9 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.13.1", "cssparser", @@ -6379,6 +6228,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -6423,9 +6278,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" dependencies = [ "base64 0.21.7", "serde", @@ -6495,7 +6350,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.62.2", + "windows", ] [[package]] @@ -6513,9 +6368,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.1", "block2", @@ -6533,29 +6388,29 @@ dependencies = [ "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", "windows-version", "x11-dl", ] [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -6581,14 +6436,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -6628,18 +6483,18 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows", ] [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -6647,6 +6502,8 @@ dependencies = [ "semver", "serde", "serde_json", + "static_vcruntime", + "strsim", "tauri-utils", "tauri-winres", "walkdir", @@ -6654,16 +6511,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png 0.18.1", "proc-macro2", "quote", "semver", @@ -6681,9 +6538,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -6751,40 +6608,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-notification" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" -dependencies = [ - "log", - "notify-rust", - "rand 0.9.5", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", - "time", - "url", -] - -[[package]] -name = "tauri-plugin-single-instance" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" -dependencies = [ - "serde", - "serde_json", - "tauri", - "thiserror 2.0.19", - "tracing", - "windows-sys 0.60.2", - "zbus", -] - [[package]] name = "tauri-plugin-updater" version = "2.9.0" @@ -6792,11 +6615,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", - "infer", + "infer 0.19.0", "log", "minisign-verify", "osakit", @@ -6818,9 +6641,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" dependencies = [ "cookie", "dpi", @@ -6838,14 +6661,14 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" dependencies = [ "gtk", "http", @@ -6863,15 +6686,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" dependencies = [ "anyhow", "brotli", @@ -6881,7 +6704,7 @@ dependencies = [ "dunce", "glob", "http", - "infer", + "infer 0.22.0", "json-patch", "log", "memchr", @@ -6923,7 +6746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" dependencies = [ "thiserror 2.0.19", - "windows 0.62.2", + "windows", "windows-version", ] @@ -7148,21 +6971,6 @@ dependencies = [ "toml_edit 0.20.2", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -7187,15 +6995,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -7456,12 +7255,12 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -7500,58 +7299,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicase" version = "2.9.0" @@ -7613,13 +7360,13 @@ dependencies = [ [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -7919,16 +7666,14 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows", + "windows-core", ] [[package]] @@ -7944,13 +7689,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.19", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", ] [[package]] @@ -8008,51 +7753,30 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "windows-version", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -8061,20 +7785,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -8085,20 +7796,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -8107,9 +7807,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -8134,12 +7834,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -8159,33 +7853,14 @@ dependencies = [ "zeroize", ] -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -8194,16 +7869,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -8212,7 +7878,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8257,7 +7923,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8297,7 +7963,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -8308,22 +7974,13 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8332,7 +7989,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8482,12 +8139,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" @@ -8530,15 +8181,15 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -8566,8 +8217,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", "windows-version", "x11-dl", ] @@ -8679,78 +8330,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow 1.0.4", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.56" @@ -8886,43 +8465,3 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] - -[[package]] -name = "zvariant" -version = "5.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.4", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", - "winnow 1.0.4", -] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 3ad4d5e1b..36164e63e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ default = ["dictation"] plugin-test-bridge = [] approval-test-bridge = [] browser-preview = [] +windows-browser-functional-preview = ["browser-preview", "colossus-native-browser/windows-browser-functional-preview", "tauri/custom-protocol"] embedded-chromium-preview = ["browser-preview", "colossus-native-browser/cef-preview"] browser-test-bridge = ["browser-preview", "colossus-native-browser/native-test-driver"] # Separate-host Debug lane: link only the native pixel adapter; never bootstrap @@ -53,7 +54,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10.9" -tauri-build = { version = "=2.6.3", features = [] } +tauri-build = { version = "=2.7.3", features = [] } [dependencies] async-trait = "0.1.89" @@ -88,7 +89,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" serde-saphyr = "0.0.29" sha2 = "0.10.9" -tauri = { version = "=2.11.5", features = ["unstable"] } +tauri = { version = "=2.12.4", features = ["unstable"] } tauri-plugin-dialog = "2.7.2" # Shipped Windows updates are signed raw NSIS installers; legacy ZIP extraction is disabled. tauri-plugin-updater = { version = "=2.9.0", default-features = false, features = ["rustls-tls"] } @@ -107,15 +108,13 @@ rustix = { version = "1.1.4", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] colossus-windows-native = { version = "=0.11.8", path = "../../../crates/colossus-windows-native" } keyring-core = "=1.0.0" -tauri-plugin-single-instance = "=2.4.2" windows-native-keyring-store = "=1.1.0" [target.'cfg(any(target_os = "macos", windows))'.dependencies] -tauri = { version = "=2.11.5", features = ["tray-icon", "image-png"] } -tauri-plugin-notification = "=2.4.0" +tauri = { version = "=2.12.4", features = ["tray-icon", "image-png"] } +notify-rust = { version = "=4.18.1", default-features = false } [dev-dependencies] -keyring = "4.1.4" rcgen = { version = "0.14.8", default-features = false, features = ["crypto", "pem", "ring", "zeroize"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"] } tempfile = "3.23.0" diff --git a/apps/desktop/src-tauri/installer-browser-preview-hooks.nsh b/apps/desktop/src-tauri/installer-browser-preview-hooks.nsh new file mode 100644 index 000000000..246be4999 --- /dev/null +++ b/apps/desktop/src-tauri/installer-browser-preview-hooks.nsh @@ -0,0 +1,18 @@ +; Preserve the supported data-retention/uninstall behavior verbatim. +!include "${__FILEDIR__}\installer-hooks.nsh" + +; NSIS copies bytes rather than source ACLs. Chromium's sandboxed helpers need +; read/execute access to this public engine subtree only. No profiles, keys or +; installation ancestors receive package grants, and no elevation is requested. +!macro NSIS_HOOK_POSTINSTALL + IfFileExists "$INSTDIR\browser\component\libcef.dll" +3 + MessageBox MB_OK|MB_ICONSTOP "The browser preview component is missing. Installation cannot finish." /SD IDOK + Abort + nsExec::ExecToStack '"$SYSDIR\icacls.exe" "$INSTDIR\browser\component" /grant "*S-1-15-2-1:(OI)(CI)(RX)" "*S-1-15-2-2:(OI)(CI)(RX)"' + Pop $0 + Pop $1 + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "Windows could not prepare the browser preview sandbox resources. Installation cannot finish." /SD IDOK + Abort + ${EndIf} +!macroend diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs index e136c71c8..088cc1b6d 100644 --- a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic.rs @@ -29,7 +29,8 @@ use colossus_sdk::{ ApiMajor, AppPrivateInstanceDir, CancelRunRequest, Colossus, CreateRunRequest, GetRunRequest, IdempotencyKey, InputContentPart, InstanceId, ManagedAccessProfile, ManagedExecutionBoundary, ManagedFieldOverride, ManagedProviderKind, ManagedRuntimeConfig, NativeSidecarLifecycle, - RunMode, RunStatus, SidecarApplicationGrant, SidecarBootstrapConfig, SidecarOptions, + RunMode, RunStatus, RunTerminal, SidecarApplicationGrant, SidecarBootstrapConfig, + SidecarOptions, }; use serde_json::{Value, json}; use tauri::Manager as _; @@ -56,6 +57,8 @@ use windows_frames::{accepted_frame, focus}; mod native_input; mod owned_tabs; mod viewport_heartbeat; +#[cfg(windows)] +mod wait_continuity; use viewport_heartbeat::ViewportHeartbeat; const SPACE: &str = if cfg!(windows) { @@ -63,11 +66,12 @@ const SPACE: &str = if cfg!(windows) { } else { "mac-contained-acceptance" }; -const TOOLS: [&str; 11] = [ +const TOOLS: [&str; 12] = [ "browser.open", "browser.status", "browser.snapshot", "browser.click", + "browser.wait", "browser.navigate", "browser.screenshot", "browser.close", @@ -107,6 +111,7 @@ struct FixtureState { phase: AtomicUsize, model_seen: AtomicBool, handoff_done: AtomicBool, + post_click_snapshot: AtomicBool, click_observed: AtomicBool, navigation_observed: AtomicBool, screenshot_seen: AtomicBool, @@ -116,6 +121,8 @@ struct FixtureState { navigated_target: Mutex>, run_id: Mutex>, failure: Mutex>, + #[cfg(windows)] + wait_continuity: wait_continuity::State, } fn close_after_completion() -> bool { @@ -320,7 +327,7 @@ async fn serve( match (request.method.as_str(), request.path.as_str()) { ("GET", "/first") => { let page: &[u8] = if cfg!(windows) { - br#"Human fixture

Human fixture

Waiting for Core"# + include_bytes!("contained_diagnostic/wait_continuity.html") } else if state.native_input { include_bytes!("native_input.html") } else { @@ -344,24 +351,38 @@ async fn serve( ("GET", "/favicon.ico") => { respond(&mut stream, 200, "image/x-icon", &[]).await?; } + #[cfg(windows)] + ("GET", "/wait-release") => { + wait_continuity::release_response(&mut stream, state).await?; + } ("POST", "/v1/chat/completions") => { let body: Value = serde_json::from_slice(&request.body)?; ensure!( body["stream"] == false, "fixture requires non-streaming model" ); - let phase = state.phase.fetch_add(1, Ordering::AcqRel); + let turn = state.phase.fetch_add(1, Ordering::AcqRel); ensure!( - phase <= if state.native_tabs { 11 } else { 7 }, - "unexpected extra model turn {phase}" + turn <= (if state.native_tabs { 11 } else { 7 }) + usize::from(cfg!(windows)), + "unexpected extra model turn {turn}" ); + #[cfg(windows)] + let phase = wait_continuity::logical_phase(turn); + #[cfg(not(windows))] + let phase = turn; state.signal(&state.model_seen); if phase == 0 { state .wait(&state.handoff_done, "native human-to-agent handoff") .await?; } - if phase == 3 { + if phase == 4 { + let observed = tool_result(&body, "fixture-3")?; + ensure!( + observed["snapshot"]["snapshot_id"].is_string(), + "missing post-click snapshot" + ); + state.signal(&state.post_click_snapshot); state .wait(&state.click_observed, "visible Core click") .await?; @@ -375,6 +396,16 @@ async fn serve( .wait(&state.navigation_observed, "visible Core navigation") .await?; } + #[cfg(windows)] + let response = if turn == 4 { + wait_continuity::model_wait(&body, state)? + } else { + if turn == 5 { + wait_continuity::verify_result(&body, state)?; + } + model_response(phase, &body, origin, state).await? + }; + #[cfg(not(windows))] let response = model_response(phase, &body, origin, state).await?; let encoded = serde_json::to_vec(&response)?; respond(&mut stream, 200, "application/json", &encoded).await?; @@ -609,6 +640,18 @@ async fn model_response( .is_some_and(|hash| hash.len() == 64), "Core screenshot did not publish verified PNG artifact metadata" ); + ensure!( + body["messages"] + .as_array() + .is_some_and(|messages| messages.iter().any(|message| message["content"] + .as_array() + .is_some_and(|parts| parts.iter().any(|part| part["type"] + == "image_url" + && part["image_url"]["url"].as_str().is_some_and( + |url| url.starts_with("data:image/png;base64,iVBOR") + ))))), + "Screenshot metadata reached the model but its PNG image did not" + ); state.signal(&state.screenshot_seen); if state.native_tabs { return owned_tabs::response(phase, body, origin, state).await; @@ -678,7 +721,12 @@ fn managed_runtime(origin: &str, visual_hold: Duration) -> ManagedRuntimeConfig 90_000 }; runtime.models[0].model = "mac-browser-fixture".into(); + // Unknown model names deliberately use conservative image token estimates. + // Leave room for the real screenshot and bounded tool transcript so this + // native acceptance reaches the model without bypassing context accounting. + runtime.models[0].context_window_tokens = 131_072; runtime.models[0].capabilities.streaming = false.into(); + runtime.models[0].capabilities.image_inputs = true.into(); runtime.field_overrides = vec![ ManagedFieldOverride { field_id: "access.tools.include".into(), @@ -693,6 +741,7 @@ fn managed_runtime(origin: &str, visual_hold: Duration) -> ManagedRuntimeConfig "browser.status", "browser.snapshot", "browser.click", + "browser.wait", "browser.navigate", "browser.screenshot", "browser.close", @@ -1329,7 +1378,7 @@ fn request(conversation_id: String) -> anyhow::Result { research_sources: Vec::new(), plan_action: None, branch: None, - max_turns: 12, + max_turns: 12 + u32::from(cfg!(windows)), idempotency_key: IdempotencyKey::new(format!( "mac-contained-browser-{}", uuid::Uuid::now_v7() @@ -1374,15 +1423,46 @@ async fn exercise_owned( ) -> anyhow::Result<()> { let state = app.state::(); state.select_target(Some(SPACE.into())).await; - let snapshot = action( + let blank = action( app, BrowserAction::New { conversation_id: None, + url: String::new(), + }, + ) + .await?; + let blank_id = blank.selected_tab_id.context("blank tab missing")?; + let blank_record = blank + .tabs + .iter() + .find(|tab| tab.id == blank_id) + .context("blank record missing")?; + ensure!( + blank_record.session_id.is_empty() + && !blank_record.page.loading + && blank_record.error.is_none(), + "address-entry tab allocated a native session or reported loading" + ); + ensure!( + app.webviews().len() == 1, + "blank tab allocated a guest webview" + ); + let snapshot = action( + app, + BrowserAction::Navigate { + tab_id: blank_id, url: format!("{}/first", fixture.origin), }, ) .await .context("stage=desktop-new-native-admission")?; + ensure!( + snapshot.tabs.len() == 1, + "first navigation retained its empty tab" + ); + println!( + "PASS empty new tab allocates no guest and first navigation replaces it through native admission" + ); ensure!( snapshot.engine.kind == if cfg!(windows) { @@ -1527,19 +1607,25 @@ async fn exercise_owned( println!("PASS authenticated native human-to-agent handoff"); title(&view, "Agent click received").await?; + fixture + .state + .wait( + &fixture.state.post_click_snapshot, + "Core snapshot after click", + ) + .await?; #[cfg(windows)] windows::agent_click_indicator(app, &tab, &view, rect, home).await?; fixture.state.signal(&fixture.state.click_observed); println!("PASS Core browser.snapshot and browser.click changed the visible page"); + #[cfg(windows)] + wait_continuity::checkpoint(client, &run.run_id, &view, fixture, heartbeat).await?; title(&view, "Agent controlled page").await?; #[cfg(windows)] println!("EVIDENCE Core navigation title acknowledged; awaiting matching native frame"); - let page = view.inspect().await?; - ensure!( - page.url == format!("{}/agent", fixture.origin), - "Core navigation reached the wrong page: {}", - page.url - ); + // Core may begin its next finite observation after the title receipt. The + // authenticated frame loop below tolerates transient Stale receipts and + // independently requires the exact URL, Core target and current page pixels. let core_target = fixture.state.navigated_target().await?; let probe = accepted_frame( app, @@ -1574,9 +1660,38 @@ async fn exercise_owned( if fixture.state.screenshot_seen.load(Ordering::Acquire) { break; } + let observed_run = tokio::time::timeout( + Duration::from_secs(1), + client.get_run(GetRunRequest { + run_id: run.run_id.clone(), + }), + ) + .await + .context("Core screenshot continuation run status timed out")?? + .run; + if fixture.state.screenshot_seen.load(Ordering::Acquire) { + break; + } + if matches!( + observed_run.status, + RunStatus::Completed + | RunStatus::Failed + | RunStatus::Cancelled + | RunStatus::Interrupted + | RunStatus::OutcomeUnknown + ) { + let reason = match observed_run.terminal.as_ref() { + Some(RunTerminal::Failure(failure)) => failure.reason.as_str(), + _ => "no released failure", + }; + anyhow::bail!( + "Core browser run ended with {:?} before screenshot reached the model: {reason}", + observed_run.status + ); + } ensure!( tokio::time::Instant::now() < deadline, - "Core browser.screenshot did not publish its verified PNG" + "Core browser.screenshot did not reach the model with verified PNG metadata and image bytes" ); tokio::time::sleep(Duration::from_millis(100)).await; } @@ -1633,15 +1748,87 @@ async fn exercise_owned( .await .map_err(|error| anyhow::anyhow!(error.message))?; #[cfg(feature = "windows-browser-development")] - ensure!( + { + let completed_view = { + let registry = state + .browser + .lock() + .map_err(|error| anyhow::anyhow!(error.message))?; + ensure!( + registry.tabs.len() == 1 && registry.selected.get(SPACE) == Some(&tab), + "a completed run removed or replaced its selected Desktop tab" + ); + let completed = registry + .tabs + .iter() + .find(|entry| entry.dto.id == tab) + .context("completed Desktop tab is missing")?; + ensure!( + completed.dto.control == "completed" + && completed.dto.completed_snapshot + && completed.dto.page.url == format!("{}/agent", fixture.origin) + && completed.dto.page.title == "Agent controlled page" + && !completed.dto.page.loading + && !completed.dto.page.can_go_back + && !completed.dto.page.can_go_forward + && completed.dto.error.is_none() + && completed.dto.popup_url.is_none(), + "completed tab lost its retained page or kept live navigation state" + ); + ensure!( + matches!(&completed.view, BrowserView::Snapshot(_)) + && !completed.view.human_control_available() + && completed.view.contained_session_id().is_none(), + "completed page retained native browser or human input authority" + ); + completed.view.clone() + }; + ensure!( + completed_view.inspect().await.is_err() + && completed_view + .control(colossus_native_browser::NavigationAction::Reload) + .await + .is_err() + && completed_view + .navigate(tauri::Url::parse(&format!("{}/first", fixture.origin))?) + .is_err() + && completed_view.handoff(&run.run_id).await.is_err(), + "completed snapshot admitted native observation, navigation, control or handoff" + ); + state.select_target(None).await; + state.select_target(Some(SPACE.into())).await; state .browser - .lock() - .map_err(|error| anyhow::anyhow!(error.message))? - .tabs - .is_empty(), - "a completed run left its viewer marked as a crashed tab" - ); + .watch_agent(app, SPACE, &run.run_id, &conversation) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + let selected = action( + app, + BrowserAction::Select { + tab_id: tab.clone(), + }, + ) + .await?; + ensure!( + selected.tabs.len() == 1 + && selected.selected_tab_id.as_deref() == Some(tab.as_str()) + && selected.tabs[0].id == tab + && selected.tabs[0].control == "completed" + && selected.tabs[0].completed_snapshot + && selected.tabs[0].error.is_none(), + "selection or context reconciliation removed the completed snapshot tab" + ); + println!( + "PASS completed Core run retained its selected read-only page snapshot across context reconciliation without native browser authority" + ); + action( + app, + BrowserAction::Close { + tab_id: tab.clone(), + }, + ) + .await?; + } } if !close_after_completion() { #[cfg(windows)] @@ -1741,7 +1928,7 @@ async fn exercise_owned( if close_after_completion() { #[cfg(feature = "windows-browser-development")] println!( - "PASS completed Core run automatically retired its viewer through acknowledged detach without a crashed tab" + "PASS explicit close removed the completed page snapshot after acknowledged native detach" ); #[cfg(not(feature = "windows-browser-development"))] println!( @@ -1823,8 +2010,8 @@ pub(super) async fn exercise( } ); } - fixture.state.check()?; exercise?; + fixture.state.check()?; drain.map_err(|error| anyhow::anyhow!(error.message))?; close?; println!( diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.html b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.html new file mode 100644 index 000000000..7e1bb6197 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.html @@ -0,0 +1,74 @@ + + + + Human fixture + + + +

Human fixture

+ + + + Waiting for Core + + + + diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.rs new file mode 100644 index 000000000..9c12db6dc --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity.rs @@ -0,0 +1,232 @@ +//! Real frame continuity while a finite Core wait owns the unchanged document. +use super::{ + BrowserTarget, Fixture, FixtureState, RemoteAcceptedFrame, RemoteSurface, ViewportHeartbeat, + alias, browser_target, respond, same_document_frame_after, tool_result, +}; +use anyhow::{Context as _, ensure}; +use colossus_sdk::{Colossus, RunUpdateKind, ToolActivityState, WatchRunRequest}; +use serde_json::{Value, json}; +use std::{ + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::net::TcpStream; + +const CALL: &str = "fixture-wait"; +const TIMEOUT: Duration = Duration::from_secs(8); +// The existing cursor-indicator screen checkpoint runs before Core may wait. +const RELEASE_TIMEOUT: Duration = Duration::from_secs(20); +const TITLE: &str = "Agent wait complete"; + +#[derive(Default)] +pub(super) struct State { + timer_ready: AtomicBool, + release: AtomicBool, + completed: AtomicBool, + target: Mutex>, +} + +pub(super) fn logical_phase(turn: usize) -> usize { + turn - usize::from(turn > 4) +} + +pub(super) async fn release_response( + stream: &mut TcpStream, + state: &FixtureState, +) -> anyhow::Result<()> { + ensure!( + !state + .wait_continuity + .timer_ready + .swap(true, Ordering::AcqRel), + "the Core fixture clicked its wait trigger more than once" + ); + state.changed.notify_waiters(); + tokio::time::timeout( + RELEASE_TIMEOUT, + state.wait(&state.wait_continuity.release, "native wait frames"), + ) + .await + .context("same-document wait release exceeded its deadline")??; + respond(stream, 200, "text/plain", b"release same document").await +} + +pub(super) fn model_wait(body: &Value, state: &FixtureState) -> anyhow::Result { + let snapshot = tool_result(body, "fixture-3")?; + let node = snapshot["snapshot"]["nodes"] + .as_array() + .and_then(|nodes| { + nodes + .iter() + .find(|node| node["name"] == "Agent wait target") + }) + .context("fresh Core snapshot did not expose the offscreen wait target")?; + let target = browser_target(&snapshot["tab"])?; + let mut args = state + .target + .lock() + .unwrap() + .clone() + .context("missing wait scope")?; + args["tab_id"] = snapshot["tab"]["tab_id"].clone(); + args["document_id"] = snapshot["tab"]["document_id"].clone(); + args["condition"] = json!({"kind":"element_visible", "element":node["element"]}); + args["timeout_ms"] = json!(10_000); + *state.wait_continuity.target.lock().unwrap() = Some(target); + let mut response = super::model_tool(4, &alias(body, "browser.wait")?, &args); + response["choices"][0]["message"]["tool_calls"][0]["id"] = json!(CALL); + Ok(response) +} + +pub(super) fn verify_result(body: &Value, state: &FixtureState) -> anyhow::Result<()> { + let result = tool_result(body, CALL)?; + ensure!( + state.wait_continuity.release.load(Ordering::Acquire) + && Some(&browser_target(&result["tab"])?) + == state.wait_continuity.target.lock().unwrap().as_ref() + && result["tab"]["title"] == TITLE, + "Core wait completed early or changed its owned document" + ); + state.signal(&state.wait_continuity.completed); + Ok(()) +} + +async fn started(updates: &mut colossus_sdk::RunUpdates) -> anyhow::Result<()> { + while let Some(update) = updates.next_update().await { + if let RunUpdateKind::ToolActivity(activity) = update?.update + && activity.call_id == CALL + { + ensure!( + activity.tool_name == "browser.wait", + "wait activity named a different tool" + ); + match activity.state { + ToolActivityState::Started => return Ok(()), + ToolActivityState::Requested | ToolActivityState::WaitingApproval => {} + _ => anyhow::bail!("Core wait ended before its native frame checkpoint"), + } + } + } + anyhow::bail!("Core feed ended before the wait started") +} + +async fn frame( + view: &RemoteSurface, + state: &FixtureState, + previous: Option, +) -> anyhow::Result { + let probe = view.acceptance_probe().await?; + let frame = probe + .frame + .context("pending Core wait lost its authenticated native frame")?; + ensure!( + probe.visible + && !probe.page.loading + && probe.page.title == "Agent click received" + && Some(&probe.page.target) == state.wait_continuity.target.lock().unwrap().as_ref() + && frame.lease.control_generation > 0 + && frame.sequence > 0 + && frame.count > 0 + && frame.center_bgra == [255, 241, 231, 255], + "pending Core wait hid or replaced its unchanged agent page" + ); + if let Some(previous) = previous { + ensure!( + frame.lease == previous.lease && same_document_frame_after(frame, previous), + "pending Core wait stopped authenticated frame continuity" + ); + } + let screen = view.acceptance_windows_screen().await?; + ensure!( + screen.foreground + && screen.visible + && screen.hit_test + && screen.center_bgra == [255, 241, 231, 255], + "pending Core wait blanked its actual native HWND" + ); + Ok(frame) +} + +fn pending(update: RunUpdateKind) -> anyhow::Result<()> { + match update { + RunUpdateKind::ToolActivity(activity) if activity.call_id == CALL => { + ensure!( + activity.tool_name == "browser.wait" + && matches!(activity.state, ToolActivityState::Started), + "Core wait reached a terminal outcome before native continuity was proved" + ); + } + RunUpdateKind::Result(_) + | RunUpdateKind::Failure { .. } + | RunUpdateKind::Cancellation(_) => { + anyhow::bail!("Core run ended before pending-wait native continuity was proved"); + } + _ => {} + } + Ok(()) +} + +pub(super) async fn checkpoint( + client: &Colossus, + run_id: &str, + view: &RemoteSurface, + fixture: &Fixture, + heartbeat: &ViewportHeartbeat, +) -> anyhow::Result<()> { + tokio::time::timeout(TIMEOUT, async { + let mut updates = client + .watch_run(WatchRunRequest { + run_id: run_id.into(), + after_sequence: 0, + }) + .await?; + started(&mut updates).await?; + let continuity = async { + fixture + .state + .wait( + &fixture.state.wait_continuity.timer_ready, + "the page's finite wait timer", + ) + .await?; + let mut previous = None; + for _ in 0..4 { + heartbeat.check()?; + fixture.state.check()?; + ensure!( + !fixture.state.wait_continuity.completed.load(Ordering::Acquire), + "Core wait completed before frame continuity was proved" + ); + previous = Some(frame(view, &fixture.state, previous).await?); + tokio::time::sleep(Duration::from_millis(150)).await; + } + Ok::<(), anyhow::Error>(()) + }; + tokio::pin!(continuity); + loop { + tokio::select! { + biased; + update = updates.next_update() => pending(update.context("Core feed ended during its pending wait")??.update)?, + result = &mut continuity => { result?; break; } + } + } + fixture.state.signal(&fixture.state.wait_continuity.release); + fixture + .state + .wait( + &fixture.state.wait_continuity.completed, + "Core's released same-document wait result", + ) + .await?; + println!("PASS pending Core browser.wait kept advancing authenticated native frames and visible HWND pixels under the same agent document and writer"); + Ok::<(), anyhow::Error>(()) + }) + .await + .context("same-document Core wait continuity exceeded its deadline")? +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity/tests.rs b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity/tests.rs new file mode 100644 index 000000000..022a31a5e --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/acceptance/contained_diagnostic/wait_continuity/tests.rs @@ -0,0 +1,104 @@ +use super::*; + +#[test] +fn native_tabs_budget_includes_the_wait_and_final_core_close_confirmation() { + let request = super::super::request("fixture-owned-conversation".into()).unwrap(); + let last_turn = usize::try_from(request.max_turns - 1).unwrap(); + assert_eq!(logical_phase(last_turn), 11); + assert_eq!(logical_phase(4), 4); + assert_eq!(logical_phase(5), 4); +} + +fn state() -> FixtureState { + FixtureState { + target: Mutex::new(Some(json!({"session_id":format!("bs_{}", "1".repeat(32)), + "control_generation":3,"tab_id":format!("bt_{}", "1".repeat(32)), + "document_id":format!("bd_{}", "1".repeat(32))}))), + ..FixtureState::default() + } +} + +fn snapshot() -> Value { + json!({"tab":{"tab_id":format!("bt_{}", "2".repeat(32)), + "document_id":format!("bd_{}", "2".repeat(32))}, + "snapshot":{"nodes":[{"name":"Agent wait target","element":{ + "document_id":format!("bd_{}", "2".repeat(32)), + "snapshot_id":format!("bn_{}", "3".repeat(32)), + "element_id":format!("be_{}", "4".repeat(32))}}]}}) +} + +fn body(call: &str, result: &Value) -> Value { + json!({"tools":[{"function":{"name":"browser_wait"}}], + "messages":[{"role":"tool","tool_call_id":call,"content":result.to_string()}]}) +} + +#[test] +fn wait_uses_the_fresh_post_click_reference_without_reusing_the_prior_document() { + let state = state(); + let snapshot = snapshot(); + let response = model_wait(&body("fixture-3", &snapshot), &state).unwrap(); + let call = &response["choices"][0]["message"]["tool_calls"][0]; + assert_eq!(call["id"], CALL); + assert_eq!(call["function"]["name"], "browser_wait"); + let args: Value = + serde_json::from_str(call["function"]["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["tab_id"], snapshot["tab"]["tab_id"]); + assert_eq!(args["document_id"], snapshot["tab"]["document_id"]); + assert_eq!(args["control_generation"], 3); + assert_eq!(args["timeout_ms"], 10_000); + assert_eq!(args["condition"]["kind"], "element_visible"); + assert_eq!( + args["condition"]["element"], + snapshot["snapshot"]["nodes"][0]["element"] + ); + let mut missing = snapshot; + missing["snapshot"]["nodes"] = json!([]); + assert!(model_wait(&body("fixture-3", &missing), &state).is_err()); +} + +#[test] +fn native_wait_requires_frame_release_and_the_exact_same_document_at_completion() { + let state = state(); + let snapshot = snapshot(); + model_wait(&body("fixture-3", &snapshot), &state).unwrap(); + let mut result = json!({"tab":snapshot["tab"]}); + result["tab"]["title"] = json!(TITLE); + assert!(verify_result(&body(CALL, &result), &state).is_err()); + state.signal(&state.wait_continuity.release); + let mut foreign = result.clone(); + foreign["tab"]["document_id"] = json!(format!("bd_{}", "5".repeat(32))); + assert!(verify_result(&body(CALL, &foreign), &state).is_err()); + let mut premature = result.clone(); + premature["tab"]["title"] = json!("Agent click received"); + assert!(verify_result(&body(CALL, &premature), &state).is_err()); + assert!(!state.wait_continuity.completed.load(Ordering::Acquire)); + verify_result(&body(CALL, &result), &state).unwrap(); + assert!(state.wait_continuity.completed.load(Ordering::Acquire)); +} + +#[test] +fn terminal_tool_activity_cannot_be_mistaken_for_an_in_progress_wait() { + let activity = colossus_sdk::ToolActivity { + call_id: CALL.into(), + tool_name: "browser.wait".into(), + state: ToolActivityState::Started, + summary: String::new(), + input: None, + preview: None, + }; + assert!(pending(RunUpdateKind::ToolActivity(activity.clone())).is_ok()); + for state in [ + ToolActivityState::Completed, + ToolActivityState::Failed, + ToolActivityState::Cancelled, + ToolActivityState::OutcomeUnknown, + ] { + assert!( + pending(RunUpdateKind::ToolActivity(colossus_sdk::ToolActivity { + state, + ..activity.clone() + })) + .is_err() + ); + } +} diff --git a/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs b/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs index e5c17c34b..90296acec 100644 --- a/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs +++ b/apps/desktop/src-tauri/src/browser/acceptance/windows_frames.rs @@ -37,12 +37,13 @@ pub(super) async fn accepted_frame( let deadline = tokio::time::Instant::now() + PAGE_TIMEOUT; loop { present(app, id, rect).await?; - let probe = match view.acceptance_probe().await { - Ok(probe) => Some(probe), - Err(BrowserError::Stale | BrowserError::Unavailable) => None, + let (probe, observation) = match view.acceptance_probe().await { + Ok(probe) => (Some(probe), "ready"), + Err(BrowserError::Stale) => (None, "stale"), + Err(BrowserError::Unavailable) => (None, "unavailable"), Err(error) => return Err(error.into()), }; - if let Some(probe) = probe { + if let Some(probe) = probe.as_ref() { let target_matches = expected .core_target .is_none_or(|target| probe.page.target == *target); @@ -70,14 +71,33 @@ pub(super) async fn accepted_frame( && frame.count > 0 && frame.center_bgra == expected.center_bgra { - return Ok(probe); + return Ok(probe.clone()); } } } - ensure!( - tokio::time::Instant::now() < deadline, - "native HWND did not accept an authenticated frame for the current document and fixture color" - ); + if tokio::time::Instant::now() >= deadline { + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + let active = colossus_native_browser::is_active(&controller).await?; + eprintln!( + "EVIDENCE Windows frame timeout observation={observation} controller_active={active} visible={} loading={} target_match={} frame_present={} color_match={}", + probe.as_ref().is_some_and(|probe| probe.visible), + probe.as_ref().is_some_and(|probe| probe.page.loading), + probe + .as_ref() + .is_some_and(|probe| probe.page.title == expected.title + && probe.page.url == expected.url), + probe.as_ref().is_some_and(|probe| probe.frame.is_some()), + probe + .as_ref() + .and_then(|probe| probe.frame) + .is_some_and(|frame| frame.center_bgra == expected.center_bgra), + ); + anyhow::bail!( + "native HWND did not accept an authenticated frame for the current document and fixture color" + ); + } tokio::time::sleep(Duration::from_millis(100)).await; } } diff --git a/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs b/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs index 28e41b5d9..fdee2deb6 100644 --- a/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs +++ b/apps/desktop/src-tauri/src/browser/acceptance/windows_remote.rs @@ -84,6 +84,50 @@ fn cursor_edge(screen: &WindowsScreenEvidence) -> anyhow::Result { Ok(usize::try_from((y * u64::from(screen.width) + x) * 4)?) } +async fn background_visibility( + app: &tauri::AppHandle, + tab: &str, + view: &RemoteSurface, + rect: BrowserRect, +) -> anyhow::Result<()> { + let other = tauri::WindowBuilder::new(app, "browser-focus-fixture") + .title("Browser focus acceptance") + .inner_size(160.0, 100.0) + .build()?; + other.set_focus()?; + tokio::time::sleep(Duration::from_millis(250)).await; + let result = async { + let controller = app + .get_webview("main") + .context("Desktop controller missing")?; + ensure!( + !colossus_native_browser::is_active(&controller).await?, + "fixture must transfer foreground focus" + ); + present(app, tab, rect).await?; + ensure!( + view.acceptance_probe().await?.visible, + "switching apps hid the browser pixels" + ); + ensure!( + !colossus_native_browser::is_active(&controller).await?, + "background presentation stole foreground focus" + ); + ensure!( + view.acceptance_windows_input().await.is_err(), + "background browser accepted human input" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + other.close()?; + super::focus(app).await?; + result?; + let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + println!("PASS background browser retains visible pixels without accepting human input"); + Ok(()) +} + pub(super) async fn human( app: &tauri::AppHandle, tab: &str, @@ -154,6 +198,14 @@ pub(super) async fn human( controller.eval("document.getElementById('owned-windows-overlay')?.remove();")?; let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; heartbeat.resume(tab, rect).await?; + background_visibility(app, tab, view, rect).await?; + view.acceptance_windows_keyboard_reentrancy() + .await + .context("Tao parent Return key reentrancy did not acknowledge completion")?; + // A returning native call alone is insufficient: require a fresh UI dispatch + // and visible browser pixels after the sent keyup re-entered Tao's keyboard handler. + let _ = screen(app, tab, view, rect, [255, 241, 231, 255]).await?; + println!("PASS Tao parent Return key handles nested cross-thread keyup and remains responsive"); view.acceptance_windows_input().await?; title(view, "Human input Ab3Ω😀").await?; println!( @@ -234,8 +286,18 @@ pub(super) async fn agent_click_indicator( tokio::time::sleep(Duration::from_millis(30)).await; }; save(&marked, home, "windows-agent-cursor-foreground.png")?; - tokio::time::sleep(Duration::from_millis(2400)).await; - let cleared = view.acceptance_windows_screen().await?; + tokio::time::sleep(Duration::from_secs(3)).await; + let retained = view.acceptance_windows_screen().await?; + let index = cursor_edge(&retained)?; + ensure!( + retained.pixels.get(index..index + 4) == Some(&[0, 0, 0, 255]), + "agent cursor disappeared between actions before its eight-second deadline" + ); + tokio::time::sleep(Duration::from_millis(5400)).await; + let cleared = view + .acceptance_windows_screen() + .await + .context("stage=agent-cursor-expiry-capture")?; let index = cursor_edge(&cleared)?; ensure!( cleared.pixels.get(index..index + 4) == Some(&[255, 241, 231, 255]), @@ -243,7 +305,7 @@ pub(super) async fn agent_click_indicator( ); save(&cleared, home, "windows-agent-cursor-expired.png")?; println!( - "PASS Core click shows agent cursor/target outline in actual foreground Windows pixels and removes it after expiry" + "PASS Core click keeps agent cursor/target outline through a subsequent snapshot in actual foreground Windows pixels and removes it after expiry" ); Ok(()) } diff --git a/apps/desktop/src-tauri/src/browser/commands.rs b/apps/desktop/src-tauri/src/browser/commands.rs index d5b1c8f93..b49ac6bc3 100644 --- a/apps/desktop/src-tauri/src/browser/commands.rs +++ b/apps/desktop/src-tauri/src/browser/commands.rs @@ -50,7 +50,10 @@ pub(crate) async fn browser_context( state.browser.selection_changed(selected.clone()); #[cfg(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ))] if let (Some(scope), Some(run), Some(conversation)) = (selected.clone(), run_id, conversation_id) @@ -62,7 +65,10 @@ pub(crate) async fn browser_context( } #[cfg(not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )))] let _ = (app, run_id, conversation_id); state.browser.snapshot().await diff --git a/apps/desktop/src-tauri/src/browser/dto.rs b/apps/desktop/src-tauri/src/browser/dto.rs index 911ed07c5..5beab33b5 100644 --- a/apps/desktop/src-tauri/src/browser/dto.rs +++ b/apps/desktop/src-tauri/src/browser/dto.rs @@ -8,6 +8,7 @@ pub(crate) struct BrowserTabDto { pub(crate) session_id: String, pub(crate) conversation_id: Option, pub(crate) control: &'static str, + pub(crate) completed_snapshot: bool, #[serde(flatten)] pub(crate) page: PageState, pub(crate) error: Option, @@ -50,12 +51,15 @@ impl BrowserEngineDto { } #[cfg(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" ))] { let ready = colossus_native_browser::contained::native_presentation_available(); Self { - kind: if cfg!(feature = "windows-browser-development") { + kind: if cfg!(feature = "windows-browser-functional-preview") { + "owned_chromium_functional_preview" + } else if cfg!(feature = "windows-browser-development") { "owned_chromium" } else { "contained_chromium" @@ -70,7 +74,8 @@ impl BrowserEngineDto { feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" ) )))] Self { @@ -163,3 +168,16 @@ pub(crate) struct BrowserViewportRequest { pub(crate) tab_id: Option, pub(crate) rect: Option, } + +#[cfg(all(test, feature = "windows-browser-functional-preview"))] +mod functional_preview_tests { + use super::BrowserEngineDto; + + #[test] + fn functional_preview_does_not_claim_production_or_an_attached_agent() { + let engine = BrowserEngineDto::current(); + assert_eq!(engine.kind, "owned_chromium_functional_preview"); + assert!(engine.preview); + assert!(!engine.agent_control_available); + } +} diff --git a/apps/desktop/src-tauri/src/browser/guest.rs b/apps/desktop/src-tauri/src/browser/guest.rs index 5643eb37a..63f6b6103 100644 --- a/apps/desktop/src-tauri/src/browser/guest.rs +++ b/apps/desktop/src-tauri/src/browser/guest.rs @@ -6,7 +6,10 @@ use std::path::PathBuf; feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) )))] use colossus_native_browser::BrowserEvent; @@ -16,7 +19,10 @@ use tauri::{AppHandle, Manager as _, Webview}; feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) )))] use tauri::{ @@ -56,12 +62,18 @@ pub(super) async fn create( policy, sink, } = request; + if initial_url.is_none() { + return Ok(BrowserView::Empty); + } let window = app .get_window("main") .ok_or_else(|| error("The Desktop window has closed."))?; #[cfg(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ))] { let _ = (directory, source); @@ -129,7 +141,10 @@ pub(super) async fn create( feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) )))] { @@ -181,3 +196,31 @@ pub(super) async fn create( Ok(BrowserView::System(view)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_tab_has_no_navigation_or_handoff_authority() { + let view = BrowserView::Empty; + assert!(view.conversation_id().is_none()); + assert!(view.contained_session_id().is_none()); + assert!(view.system_view().is_none()); + let page = view.inspect().await.unwrap(); + assert!(page.url.is_empty() && !page.loading); + assert_eq!( + view.navigate("https://example.com".parse().unwrap()), + Err(engine::BrowserError::Unavailable) + ); + assert_eq!( + view.control(engine::NavigationAction::Reload).await, + Err(engine::BrowserError::Unavailable) + ); + assert_eq!( + view.handoff("run").await, + Err(engine::BrowserError::Unavailable) + ); + view.close().await.unwrap(); + } +} diff --git a/apps/desktop/src-tauri/src/browser/inspection.rs b/apps/desktop/src-tauri/src/browser/inspection.rs index 11ddeab05..d7ad9a493 100644 --- a/apps/desktop/src-tauri/src/browser/inspection.rs +++ b/apps/desktop/src-tauri/src/browser/inspection.rs @@ -10,7 +10,7 @@ pub(super) const SNAPSHOT_BUDGET: Duration = Duration::from_secs(1); pub(super) async fn collect_pages( inspections: impl IntoIterator, budget: Duration, -) -> Vec<(String, PageState)> +) -> Vec<(String, Result)> where F: Future)>, { @@ -18,9 +18,7 @@ where let mut pages = Vec::new(); let _ = tokio::time::timeout(budget, async { while let Some((id, result)) = pending.next().await { - if let Ok(page) = result { - pages.push((id, page)); - } + pages.push((id, result)); } }) .await; @@ -58,7 +56,7 @@ mod tests { } #[tokio::test] - async fn failed_views_do_not_discard_other_observations() { + async fn closed_views_are_reported_alongside_healthy_pages() { let pages = collect_pages( [false, true].map(|ready| async move { ( @@ -73,7 +71,16 @@ mod tests { SNAPSHOT_BUDGET, ) .await; - assert_eq!(pages.len(), 1); - assert_eq!(pages[0].0, "true"); + assert_eq!(pages.len(), 2); + assert!( + pages + .iter() + .any(|(id, result)| id == "false" && matches!(result, Err(BrowserError::Closed))) + ); + assert!( + pages + .iter() + .any(|(id, result)| id == "true" && result.is_ok()) + ); } } diff --git a/apps/desktop/src-tauri/src/browser/manager.rs b/apps/desktop/src-tauri/src/browser/manager.rs index 0be39d43c..6c36d8a30 100644 --- a/apps/desktop/src-tauri/src/browser/manager.rs +++ b/apps/desktop/src-tauri/src/browser/manager.rs @@ -15,7 +15,10 @@ use super::{ }; #[cfg(not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )))] use crate::desktop_settings::SettingsStore; use crate::dto::CommandErrorDto; @@ -161,12 +164,32 @@ impl BrowserManager { .ok_or_else(|| error("This tab is no longer available in the selected workspace.")) } + fn mutable_tab( + &self, + id: &str, + scope: &str, + ) -> Result<(BrowserView, NavigationPolicy), CommandErrorDto> { + let state = self.lock()?; + let tab = state + .tabs + .iter() + .find(|tab| tab.dto.id == id && tab.scope == scope) + .ok_or_else(|| error("This tab is no longer available in the selected workspace."))?; + if matches!(tab.dto.control, "completed" | "expired") { + return Err(error("Continue browsing in a fresh browser session.")); + } + Ok((tab.view.clone(), tab.policy.clone())) + } + #[cfg(all( feature = "browser-test-bridge", not(feature = "embedded-chromium-preview"), not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )) ))] pub(super) fn legacy_tab( @@ -191,7 +214,10 @@ impl BrowserManager { any( feature = "embedded-chromium-preview", feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) ) ))] @@ -288,16 +314,37 @@ impl BrowserManager { state .tabs .iter() - .filter(|t| Some(&t.scope) == state.scope.as_ref()) + .filter(|t| { + Some(&t.scope) == state.scope.as_ref() + && !matches!(t.dto.control, "completed" | "expired") + }) .map(|t| (t.dto.id.clone(), t.view.clone())) .collect() }; let inspections = views .into_iter() .map(|(id, view)| async move { (id, view.inspect().await) }); - for (id, mut page) in collect_pages(inspections, SNAPSHOT_BUDGET).await { + for (id, page) in collect_pages(inspections, SNAPSHOT_BUDGET).await { let mut state = self.lock()?; + let mut page = match page { + Ok(page) => page, + Err(engine::BrowserError::Expired) => { + state.event(&id, BrowserEvent::Expired); + continue; + } + Err(engine::BrowserError::Closed) => { + state.event(&id, BrowserEvent::Crashed); + continue; + } + // Navigation freshness and a slow observation do not prove a crash. + Err(_) => continue, + }; if let Some(tab) = state.tabs.iter_mut().find(|t| t.dto.id == id) { + // A completed view may have replaced the remote while its last + // inspection was in flight. Never overwrite retained metadata. + if matches!(tab.dto.control, "completed" | "expired") { + continue; + } #[cfg(windows)] { // WebView2's event-owned loading flag is independent from @@ -333,7 +380,7 @@ impl BrowserManager { conversation_id, } => self.create(app, &scope, &url, conversation_id).await?, BrowserAction::Handoff { tab_id, run_id } => { - let (view, _) = self.tab(&tab_id, &scope)?; + let (view, _) = self.mutable_tab(&tab_id, &scope)?; view.handoff(&run_id).await.map_err(engine_error)?; if let Some(tab) = self .lock()? @@ -358,12 +405,7 @@ impl BrowserManager { } } BrowserAction::Navigate { tab_id, url } => { - let (view, policy) = self.tab(&tab_id, &scope)?; - let url = engine::parse_address(&url).map_err(engine_error)?; - policy.authorize(&url).map_err(engine_error)?; - self.reset_navigation(&tab_id, Some(url.as_str()))?; - view.navigate(url) - .map_err(|_| error("This page could not be opened."))?; + self.navigate(app, &scope, &tab_id, &url).await?; } BrowserAction::Select { tab_id } => { self.tab(&tab_id, &scope)?; @@ -389,7 +431,7 @@ impl BrowserManager { .map_err(engine_error)?; } BrowserAction::OpenPopup { tab_id } => { - self.tab(&tab_id, &scope)?; + self.mutable_tab(&tab_id, &scope)?; let url = self .lock()? .tabs @@ -412,13 +454,16 @@ impl BrowserManager { BrowserAction::Stop { tab_id } => (tab_id, NavigationAction::Stop), _ => return Err(error("This browser operation is unavailable.")), }; - let (view, _) = self.tab(&id, &scope)?; + let (view, _) = self.mutable_tab(&id, &scope)?; if matches!(action, NavigationAction::Stop) { self.lock()?.event(&id, BrowserEvent::Loading(false)); } else { self.reset_navigation(&id, None)?; } - view.control(action).await.map_err(engine_error)?; + if let Err(failure) = view.control(action).await { + self.lock()?.event(&id, BrowserEvent::Failed); + return Err(engine_error(failure)); + } } } Ok(()) @@ -451,7 +496,10 @@ impl BrowserManager { ) -> Result<(std::path::PathBuf, Option), CommandErrorDto> { #[cfg(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ))] { let state = self.lock()?; @@ -465,7 +513,10 @@ impl BrowserManager { } #[cfg(not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )))] { let mut state = self.lock()?; @@ -505,28 +556,51 @@ impl BrowserManager { } } - async fn create( + async fn navigate( &self, app: &AppHandle, scope: &str, + tab_id: &str, address: &str, - conversation_id: Option, ) -> Result<(), CommandErrorDto> { - let url = if address.trim().is_empty() { - None - } else { - Some(engine::parse_address(address).map_err(engine_error)?) - }; - let policy = NavigationPolicy::default(); - if let Some(url) = &url { - policy.authorize(url).map_err(engine_error)?; + let (view, policy) = self.mutable_tab(tab_id, scope)?; + if matches!(view, BrowserView::Empty) { + // Release only this inert slot while allocating its real guest. + // Failed admission restores the address-entry tab without retrying. + let (index, placeholder) = { + let mut state = self.lock()?; + let index = state + .tabs + .iter() + .position(|tab| tab.dto.id == tab_id) + .ok_or_else(|| error("This tab has closed."))?; + (index, state.tabs.remove(index)) + }; + let result = self + .create(app, scope, address, placeholder.dto.conversation_id.clone()) + .await; + if result.is_err() { + let mut state = self.lock()?; + let index = index.min(state.tabs.len()); + state.tabs.insert(index, placeholder); + } + return result; } - let (directory, source) = self.profile(scope)?; - let id = format!("browser-{}", uuid::Uuid::new_v4()); + let url = engine::parse_address(address).map_err(engine_error)?; + policy.authorize(&url).map_err(engine_error)?; + self.reset_navigation(tab_id, Some(url.as_str()))?; + if view.navigate(url).is_err() { + self.lock()?.event(tab_id, BrowserEvent::Failed); + return Err(error("This page could not be opened.")); + } + Ok(()) + } + + fn event_sink(&self, id: &str) -> EventSink { let weak = Arc::downgrade(&self.data); - let event_id = id.clone(); - let sink: EventSink = Arc::new(move |event| { - let crashed = matches!(event, BrowserEvent::Crashed); + let event_id = id.to_owned(); + Arc::new(move |event| { + let crashed = matches!(event, BrowserEvent::Crashed | BrowserEvent::Expired); let view = weak.upgrade().and_then(|data| { let mut state = data.lock().ok()?; state.event(&event_id, event); @@ -543,7 +617,28 @@ impl BrowserManager { if let Some(view) = view { let _ = view.hide(); } - }); + }) + } + + async fn create( + &self, + app: &AppHandle, + scope: &str, + address: &str, + conversation_id: Option, + ) -> Result<(), CommandErrorDto> { + let url = if address.trim().is_empty() { + None + } else { + Some(engine::parse_address(address).map_err(engine_error)?) + }; + let policy = NavigationPolicy::default(); + if let Some(url) = &url { + policy.authorize(url).map_err(engine_error)?; + } + let (directory, source) = self.profile(scope)?; + let id = format!("browser-{}", uuid::Uuid::new_v4()); + let sink = self.event_sink(&id); let generation = self.lock()?.generation; let view = super::guest::create( app, @@ -551,7 +646,7 @@ impl BrowserManager { id: id.clone(), generation, scope: scope.to_owned(), - conversation_id, + conversation_id: conversation_id.clone(), initial_url: url.clone(), directory, source, @@ -561,17 +656,32 @@ impl BrowserManager { ) .await?; self.hide_all(); + let conversation_id = view + .conversation_id() + .map(str::to_owned) + .or(conversation_id); { let mut state = self.lock()?; let session_id = view .contained_session_id() .map(str::to_owned) + .or_else(|| matches!(view, BrowserView::Empty).then(String::new)) .or_else(|| state.sessions.get(scope).cloned()) .ok_or_else(|| error("The browser session identity is unavailable."))?; state.selected.insert(scope.to_owned(), id.clone()); state.tabs.push(Tab { scope: scope.to_owned(), view: view.clone(), + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pending_completed_view: None, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + close_requested: false, agent_run_id: None, policy, heartbeat: None, @@ -579,12 +689,13 @@ impl BrowserManager { dto: BrowserTabDto { id: id.clone(), session_id, - conversation_id: view.conversation_id().map(str::to_owned), + conversation_id, control: if view.human_control_available() { "human" } else { "agent" }, + completed_snapshot: false, page: PageState { url: url.as_ref().map(ToString::to_string).unwrap_or_default(), title: "New tab".into(), @@ -602,7 +713,10 @@ impl BrowserManager { // could race the authenticated presentation's first frame. #[cfg(not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )))] if let Some(url) = url && view.navigate(url).is_err() @@ -614,7 +728,10 @@ impl BrowserManager { #[cfg(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ))] pub(super) async fn watch_agent( &self, @@ -623,13 +740,23 @@ impl BrowserManager { run_id: &str, conversation_id: &str, ) -> Result<(), CommandErrorDto> { - #[cfg(feature = "windows-browser-development")] - if !self + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + let replacement = match self .reconcile_windows_agent(app, scope, run_id, conversation_id) .await? { - return Ok(()); - } + super::windows_viewer::AgentViewerPlan::Keep => return Ok(()), + super::windows_viewer::AgentViewerPlan::Attach => None, + super::windows_viewer::AgentViewerPlan::Replace(id) => Some(id), + }; + #[cfg(not(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + )))] + let replacement: Option = None; // Dropping an old read-only viewer preserves an active Core writer; // confirmed run cleanup has already retired its native owner otherwise. let prior = self @@ -637,22 +764,25 @@ impl BrowserManager { .tabs .iter() .filter(|tab| { - tab.scope == scope && tab.agent_run_id.as_deref().is_some_and(|id| id != run_id) + tab.scope == scope + && tab.dto.control != "completed" + && tab.agent_run_id.as_deref().is_some_and(|id| id != run_id) }) .map(|tab| tab.dto.id.clone()) .collect::>(); for id in prior { self.close(scope, &id).await?; } - if self - .lock()? - .tabs - .iter() - .any(|tab| tab.scope == scope && tab.agent_run_id.as_deref() == Some(run_id)) + if replacement.is_none() + && self + .lock()? + .tabs + .iter() + .any(|tab| tab.scope == scope && tab.agent_run_id.as_deref() == Some(run_id)) { return Ok(()); } - if self.lock()?.tabs.len() >= MAX_TABS { + if replacement.is_none() && self.lock()?.tabs.len() >= MAX_TABS { return Err(error("Close a browser tab before attaching another page.")); } let window = app @@ -668,23 +798,69 @@ impl BrowserManager { state.event(&event_id, event); } }); - let Some(view) = + let admission = async { super::contained::watch_agent(&window, scope, run_id, conversation_id, sink) .await - .map_err(engine_error)? - else { + .map_err(engine_error) + }; + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + let admitted = if let Some(id) = &replacement { + self.recover_agent_presentation(scope, id, admission) + .await? + } else { + admission.await? + }; + #[cfg(not(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + )))] + let admitted = admission.await?; + let Some(view) = admitted else { return Ok(()); }; + self.register_agent_view(scope, run_id, conversation_id, id, replacement, view) + } + + #[cfg(any( + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + fn register_agent_view( + &self, + scope: &str, + run_id: &str, + conversation_id: &str, + id: String, + replacement: Option, + view: BrowserView, + ) -> Result<(), CommandErrorDto> { let session_id = view .contained_session_id() .ok_or_else(|| error("The browser session identity is unavailable."))? .to_owned(); self.hide_all(); let mut state = self.lock()?; + if let Some(replaced) = replacement { + state.tabs.retain(|tab| tab.dto.id != replaced); + } state.selected.insert(scope.to_owned(), id.clone()); state.tabs.push(Tab { scope: scope.to_owned(), view, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pending_completed_view: None, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + close_requested: false, agent_run_id: Some(run_id.to_owned()), policy: NavigationPolicy::default(), heartbeat: None, @@ -694,6 +870,7 @@ impl BrowserManager { session_id, conversation_id: Some(conversation_id.to_owned()), control: "agent", + completed_snapshot: false, page: PageState::default(), error: None, notice: None, @@ -705,10 +882,42 @@ impl BrowserManager { pub(super) async fn close(&self, scope: &str, id: &str) -> Result<(), CommandErrorDto> { let (view, _) = self.tab(id, scope)?; + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + if let Some(tab) = self + .lock()? + .tabs + .iter_mut() + .find(|tab| tab.scope == scope && tab.dto.id == id) + { + // Once any native cleanup starts, a pending image is cleanup + // custody only. A failed close must never become a display retry. + tab.close_requested = true; + } let _ = view.hide(); view.close() .await .map_err(|_| error("This browser tab could not close."))?; + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + { + let pending = self + .lock()? + .tabs + .iter() + .find(|tab| tab.scope == scope && tab.dto.id == id) + .and_then(|tab| tab.pending_completed_view.clone()); + if let Some(pending) = pending { + pending + .close() + .await + .map_err(|_| error("The retained browser view could not close."))?; + } + } let mut state = self.lock()?; state.tabs.retain(|t| t.dto.id != id); if state @@ -737,6 +946,141 @@ impl BrowserManager { #[cfg(test)] mod tests { use super::*; + use std::time::Instant; + + #[tokio::test] + async fn expired_view_preserves_address_and_cannot_be_revived_by_late_events() { + let manager = BrowserManager::default(); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.tabs.push(Tab { + scope: "workspace".into(), + view: BrowserView::Empty, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pending_completed_view: None, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + close_requested: false, + agent_run_id: None, + policy: NavigationPolicy::default(), + heartbeat: Some(Instant::now()), + presentation_epoch: Arc::new(AtomicU64::new(1)), + dto: BrowserTabDto { + id: "expired-view".into(), + session_id: "expired-session".into(), + conversation_id: Some("conversation".into()), + control: "human", + completed_snapshot: false, + page: PageState { + url: "https://github.com/".into(), + title: "GitHub".into(), + loading: true, + ..PageState::default() + }, + error: None, + notice: None, + popup_url: None, + }, + }); + state.event("expired-view", BrowserEvent::Expired); + for event in [ + BrowserEvent::Loading(true), + BrowserEvent::Crashed, + BrowserEvent::Failed, + ] { + state.event("expired-view", event); + } + let tab = &state.tabs[0]; + assert!(tab.heartbeat.is_none()); + assert_eq!(tab.presentation_epoch.load(Ordering::Acquire), 2); + } + let snapshot = manager.snapshot().await.unwrap(); + let tab = &snapshot.tabs[0]; + assert_eq!(tab.control, "expired"); + assert_eq!(tab.page.url, "https://github.com/"); + assert_eq!(tab.page.title, "GitHub"); + assert!(!tab.page.loading); + assert!(tab.error.as_deref().unwrap().contains("session expired")); + assert!(manager.mutable_tab("expired-view", "workspace").is_err()); + manager.close("workspace", "expired-view").await.unwrap(); + assert!(manager.lock().unwrap().tabs.is_empty()); + } + + #[tokio::test] + async fn completed_view_preserves_page_metadata_and_rejects_live_actions() { + let manager = BrowserManager::default(); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state + .selected + .insert("workspace".into(), "last-view".into()); + state.tabs.push(Tab { + scope: "workspace".into(), + view: BrowserView::Empty, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pending_completed_view: None, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + close_requested: false, + agent_run_id: Some("finished-run".into()), + policy: NavigationPolicy::default(), + heartbeat: None, + presentation_epoch: Arc::new(AtomicU64::new(1)), + dto: BrowserTabDto { + id: "last-view".into(), + session_id: "closed-native-session".into(), + conversation_id: Some("conversation".into()), + control: "completed", + completed_snapshot: false, + page: PageState { + url: "https://example.com/finished".into(), + title: "Finished page".into(), + ..PageState::default() + }, + error: None, + notice: None, + popup_url: None, + }, + }); + // Already queued events from the retired attachment cannot replace + // this inert view with a crash, spinner, popup or access notice. + for event in [ + BrowserEvent::Crashed, + BrowserEvent::Loading(true), + BrowserEvent::Failed, + BrowserEvent::Popup("https://example.com/popup".into()), + BrowserEvent::Blocked, + ] { + state.event("last-view", event); + } + } + let snapshot = manager.snapshot().await.unwrap(); + assert_eq!(snapshot.selected_tab_id.as_deref(), Some("last-view")); + let tab = &snapshot.tabs[0]; + assert_eq!(tab.page.url, "https://example.com/finished"); + assert_eq!(tab.page.title, "Finished page"); + assert_eq!(tab.control, "completed"); + assert!(!tab.completed_snapshot); + assert!(!tab.page.loading && tab.error.is_none() && tab.popup_url.is_none()); + assert!(tab.notice.is_none()); + assert!(manager.tab("last-view", "workspace").is_ok()); + assert!(manager.mutable_tab("last-view", "workspace").is_err()); + assert!(manager.tab("last-view", "another-workspace").is_err()); + manager.close("workspace", "last-view").await.unwrap(); + assert!(manager.lock().unwrap().tabs.is_empty()); + } #[test] fn delayed_close_can_reach_reconciliation_but_other_hide_errors_block_teardown() { diff --git a/apps/desktop/src-tauri/src/browser/mod.rs b/apps/desktop/src-tauri/src/browser/mod.rs index cc94c2dd0..1695d7e26 100644 --- a/apps/desktop/src-tauri/src/browser/mod.rs +++ b/apps/desktop/src-tauri/src/browser/mod.rs @@ -9,7 +9,10 @@ pub(crate) mod commands; feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) ))] pub(crate) mod contained; @@ -22,7 +25,10 @@ mod registry; pub(crate) mod viewport; #[cfg(all(windows, feature = "embedded-chromium-preview"))] mod windows_entry; -#[cfg(feature = "windows-browser-development")] +#[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" +))] mod windows_viewer; pub(crate) use manager::BrowserManager; diff --git a/apps/desktop/src-tauri/src/browser/registry.rs b/apps/desktop/src-tauri/src/browser/registry.rs index c9301aed9..b7bddf277 100644 --- a/apps/desktop/src-tauri/src/browser/registry.rs +++ b/apps/desktop/src-tauri/src/browser/registry.rs @@ -17,6 +17,16 @@ pub(super) const CRASHED_MESSAGE: &str = pub(super) struct Tab { pub(super) scope: String, pub(super) view: BrowserView, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pub(super) pending_completed_view: Option, + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] + pub(super) close_requested: bool, pub(super) agent_run_id: Option, pub(super) policy: NavigationPolicy, pub(super) dto: BrowserTabDto, @@ -33,7 +43,10 @@ pub(super) struct Registry { pub(super) selected: HashMap, pub(super) profiles: HashMap, pub(super) sessions: HashMap, - #[cfg(feature = "windows-browser-development")] + #[cfg(any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ))] pub(super) viewer_recoveries: HashMap, } @@ -47,7 +60,10 @@ impl Registry { // independently supervised native surface is actually attached. #[cfg(not(any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) )))] { engine.preview = false; @@ -88,7 +104,24 @@ impl Registry { let Some(tab) = self.tabs.iter_mut().find(|t| t.dto.id == id) else { return; }; + // A late callback belongs to the retired live attachment. A static + // completed view has no page event stream and must remain inspectable. + if matches!(tab.dto.control, "completed" | "expired") { + return; + } match event { + BrowserEvent::Expired => { + tab.dto.control = "expired"; + tab.dto.page.loading = false; + tab.dto.error = Some( + "This browser session expired. Continue in a new session to browse again." + .into(), + ); + tab.dto.notice = None; + tab.dto.popup_url = None; + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + } BrowserEvent::Loading(loading) => { tab.dto.page.loading = loading; if loading { diff --git a/apps/desktop/src-tauri/src/browser/viewport.rs b/apps/desktop/src-tauri/src/browser/viewport.rs index 394ca87fc..3ed3a0e53 100644 --- a/apps/desktop/src-tauri/src/browser/viewport.rs +++ b/apps/desktop/src-tauri/src/browser/viewport.rs @@ -10,6 +10,11 @@ use std::{ }; use tauri::{AppHandle, Manager as _}; +/// Display follows the window's visibility, while native input remains focus-bound. +pub(crate) fn window_can_present(window: &tauri::Window) -> bool { + window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(true) +} + pub(crate) fn valid_rect(rect: BrowserRect, width: f64, height: f64) -> bool { [rect.x, rect.y, rect.width, rect.height, width, height] .iter() @@ -32,12 +37,7 @@ impl BrowserManager { let window = app .get_window("main") .ok_or_else(|| error("The Desktop window has closed."))?; - let controller = app - .get_webview("main") - .ok_or_else(|| error("The Desktop controller has closed."))?; - let active = colossus_native_browser::is_active(&controller) - .await - .unwrap_or(false); + let visible_window = window_can_present(&window); let scale = window .scale_factor() .map_err(|_| error("The browser viewport is unavailable."))?; @@ -62,11 +62,12 @@ impl BrowserManager { .tabs .iter_mut() .map(|tab| { - let visible = active + let visible = visible_window && tab.scope == scope && Some(&tab.dto.id) == request.tab_id.as_ref() && request.tab_id == selected - && tab.dto.error.is_none(); + && tab.dto.error.is_none() + && (tab.dto.control != "completed" || tab.dto.completed_snapshot); let rect = request .rect .filter(|rect| visible && valid_rect(*rect, size.width, size.height)); @@ -139,20 +140,6 @@ pub(crate) fn handle_window_event(window: &tauri::Window, event: &tauri::WindowE if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { window.state::().browser.hide_all(); crate::terminal_commands::pane::hide(window.app_handle(), false); - } else if matches!(event, tauri::WindowEvent::Focused(false)) { - let app = window.app_handle().clone(); - tauri::async_runtime::spawn(async move { - let active = match app.get_webview("main") { - Some(view) => colossus_native_browser::is_active(&view) - .await - .unwrap_or(false), - None => false, - }; - if !active { - app.state::().browser.hide_all(); - crate::terminal_commands::pane::hide(&app, false); - } - }); } } diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer.rs b/apps/desktop/src-tauri/src/browser/windows_viewer.rs index c99e2a698..ca1f5b95c 100644 --- a/apps/desktop/src-tauri/src/browser/windows_viewer.rs +++ b/apps/desktop/src-tauri/src/browser/windows_viewer.rs @@ -8,6 +8,16 @@ use colossus_sdk::{GetRunRequest, RunStatus}; use std::time::Duration; use tauri::{AppHandle, Manager as _}; +mod close_intent; +mod completed; +mod recovery; + +pub(super) enum AgentViewerPlan { + Keep, + Attach, + Replace(String), +} + fn terminal(status: RunStatus) -> bool { matches!( status, @@ -26,7 +36,7 @@ impl BrowserManager { scope: &str, run_id: &str, conversation_id: &str, - ) -> Result { + ) -> Result { // Only the selected conversation's current run can own this viewer. // Keep its bounded recovery budget across fresh attachments, without // accumulating counters for already replaced runs or workspaces. @@ -44,17 +54,27 @@ impl BrowserManager { ( tab.dto.id.clone(), tab.dto.error.as_deref() == Some(CRASHED_MESSAGE), + matches!(tab.dto.control, "completed" | "expired"), + tab.dto.session_id.clone(), + tab.close_requested, ) }); - let Some((id, crashed)) = existing else { - return Ok(true); + let Some((id, crashed, completed, session_id, close_requested)) = existing else { + return Ok(AgentViewerPlan::Attach); }; + if close_requested { + self.close(scope, &id).await?; + return Ok(AgentViewerPlan::Keep); + } + if completed { + return Ok(AgentViewerPlan::Keep); + } let state = app.state::(); let Some(target) = state.target(scope).await else { - return Ok(true); + return Ok(AgentViewerPlan::Keep); }; let Some(_slot) = target.try_unary_slot() else { - return Ok(true); + return Ok(AgentViewerPlan::Keep); }; let Ok(Ok(response)) = tokio::time::timeout( Duration::from_secs(1), @@ -65,38 +85,55 @@ impl BrowserManager { .await else { // Unavailable run metadata never becomes terminal/cleanup authority. - return Ok(true); + return Ok(AgentViewerPlan::Keep); }; if response.run.session_id != conversation_id || response.run.run_id != run_id { - return Ok(true); + return Ok(AgentViewerPlan::Keep); } if terminal(response.run.status) { - // Ending a viewer is distinct from claiming browser cleanup. Detach - // must acknowledge either an active writer's hidden view or Core's - // completed cleanup before any Desktop tab metadata is removed. - self.close(scope, &id).await?; + let closed = + close_intent::explicitly_closed(&target.client, &response.run, &session_id).await; + self.settle_terminal_agent(scope, &id, closed).await?; self.lock()?.viewer_recoveries.remove(run_id); - return Ok(false); + return Ok(AgentViewerPlan::Keep); } if !crashed { - return Ok(true); + return Ok(AgentViewerPlan::Keep); } { let mut registry = self.lock()?; if registry.viewer_recoveries.len() >= MAX_TABS && !registry.viewer_recoveries.contains_key(run_id) { - return Ok(true); + return Ok(AgentViewerPlan::Keep); } let attempts = registry.viewer_recoveries.entry(run_id.into()).or_default(); if *attempts >= 2 { - return Ok(true); + return Ok(AgentViewerPlan::Keep); } *attempts += 1; } - // Recover only presentation. The following normal watch obtains a fresh - // authenticated read-only admission to the existing Core-owned page. - self.close(scope, &id).await?; - Ok(true) + // Recovery retires only the failed presentation. Keep its tab and last + // metadata until a fresh admission succeeds: Core can finish while the + // next watch is awaiting its reply, leaving no live page to reattach. + Ok(AgentViewerPlan::Replace(id)) + } + + async fn settle_terminal_agent( + &self, + scope: &str, + id: &str, + explicitly_closed: Option, + ) -> Result<(), CommandErrorDto> { + match explicitly_closed { + Some(true) => self.close(scope, id).await, + closed => { + // Unknown/over-budget close evidence retains only the URL + // already displayed by Desktop, with a fresh-session path. + // It cannot authorize a new private pixel copy. + self.retain_completed_agent(scope, id, closed == Some(false)) + .await + } + } } } diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer/close_intent.rs b/apps/desktop/src-tauri/src/browser/windows_viewer/close_intent.rs new file mode 100644 index 000000000..2d74564ba --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_viewer/close_intent.rs @@ -0,0 +1,316 @@ +//! Classify explicit close from the canonical feed, without renderer authority. +use colossus_sdk::{ + Colossus, Run, RunStatus, RunUpdate, RunUpdateKind, ToolActivity, ToolActivityState, + WatchRunRequest, +}; +use futures_util::{Stream, StreamExt as _}; +use serde::Deserialize; +use std::time::Duration; +use tokio::time::Instant; + +const MAX_UPDATES: u64 = 4096; +const REPLAY_TIMEOUT: Duration = Duration::from_secs(2); + +/// `false` requires a complete canonical replay proving no matching explicit close. +/// Unavailable, malformed, incomplete or over-budget evidence is never absence. +pub(super) async fn explicitly_closed( + client: &Colossus, + run: &Run, + session_id: &str, +) -> Option { + if session_id.is_empty() + || run.run_id.is_empty() + || run.terminal.is_none() + || !matches!( + run.status, + RunStatus::Completed + | RunStatus::Failed + | RunStatus::Cancelled + | RunStatus::Interrupted + | RunStatus::OutcomeUnknown + ) + || !(1..=MAX_UPDATES).contains(&run.last_sequence) + { + return None; + } + let deadline = Instant::now() + REPLAY_TIMEOUT; + // The SDK rejects cross-run updates and sequence gaps. Dropping this read-only + // watcher at the deadline disconnects observation; it never cancels a run. + let mut updates = tokio::time::timeout_at( + deadline, + client.watch_run(WatchRunRequest { + run_id: run.run_id.clone(), + after_sequence: 0, + }), + ) + .await + .ok()? + .ok()?; + classify( + &mut updates, + &run.run_id, + run.last_sequence, + session_id, + deadline, + ) + .await +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CloseReceipt<'a> { + session_id: &'a str, + closed: bool, +} + +fn matching_close(activity: &ToolActivity, session_id: &str) -> Option { + if activity.tool_name != "browser.close" || activity.state != ToolActivityState::Completed { + return Some(false); + } + let receipt: CloseReceipt<'_> = serde_json::from_str(activity.preview.as_deref()?).ok()?; + Some(receipt.closed && receipt.session_id == session_id) +} + +async fn classify( + updates: &mut S, + run_id: &str, + last_sequence: u64, + session_id: &str, + deadline: Instant, +) -> Option +where + S: Stream> + Unpin, +{ + if !(1..=MAX_UPDATES).contains(&last_sequence) { + return None; + } + tokio::time::timeout_at(deadline, async { + let mut closed = false; + for sequence in 1..=last_sequence { + // Ready streams can otherwise process many items without yielding to + // the timeout future. Bound that synchronous work by the same deadline. + if Instant::now() >= deadline { + return None; + } + let update = updates.next().await?.ok()?; + if update.run_id != run_id || update.sequence != sequence { + return None; + } + if let RunUpdateKind::ToolActivity(activity) = update.update { + closed |= matching_close(&activity, session_id)?; + } + } + // Do not classify a suffix, a truncated replay, or a feed that advanced + // beyond the exact terminal GetRun summary as proven absence. + if updates.next().await.is_some() { + return None; + } + Some(closed) + }) + .await + .ok() + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_util::stream; + + fn activity(tool: &str, state: ToolActivityState, preview: Option<&str>) -> ToolActivity { + ToolActivity { + call_id: "call".into(), + tool_name: tool.into(), + state, + summary: String::new(), + input: None, + preview: preview.map(str::to_owned), + } + } + + fn update(sequence: u64, activity: Option) -> RunUpdate { + RunUpdate { + run_id: "run".into(), + sequence, + created_at: String::new(), + update: activity.map_or( + RunUpdateKind::State(RunStatus::Completed), + RunUpdateKind::ToolActivity, + ), + } + } + + fn close(sequence: u64) -> RunUpdate { + update( + sequence, + Some(activity( + "browser.close", + ToolActivityState::Completed, + Some(r#"{"session_id":"owned","closed":true}"#), + )), + ) + } + + async fn replay(items: Vec>, last_sequence: u64) -> Option { + classify( + &mut stream::iter(items), + "run", + last_sequence, + "owned", + Instant::now() + REPLAY_TIMEOUT, + ) + .await + } + + #[test] + fn matches_only_successful_exact_session_receipts() { + let receipt = r#"{"session_id":"owned","closed":true}"#; + let completed = ToolActivityState::Completed; + assert_eq!( + matching_close( + &activity("browser.close", completed, Some(receipt)), + "owned" + ), + Some(true) + ); + assert_eq!( + matching_close( + &activity("browser.close", completed, Some(receipt)), + "other" + ), + Some(false) + ); + assert_eq!( + matching_close( + &activity("browser.tab.close", completed, Some(receipt)), + "owned" + ), + Some(false) + ); + assert_eq!( + matching_close( + &activity("browser.close", ToolActivityState::Started, Some(receipt)), + "owned" + ), + Some(false) + ); + assert_eq!( + matching_close( + &activity("browser.close", ToolActivityState::Failed, Some(receipt)), + "owned" + ), + Some(false) + ); + assert_eq!( + matching_close( + &activity( + "browser.close", + completed, + Some(r#"{"session_id":"owned","closed":false}"#) + ), + "owned" + ), + Some(false) + ); + } + + #[test] + fn malformed_or_missing_success_receipts_are_not_absence() { + for preview in [ + None, + Some(""), + Some("preview truncated"), + Some(r#"{"session_id":"owned","closed":"true"}"#), + Some(r#"{"session_id":"owned","closed":true,"extra":1}"#), + Some(r#"{"session_id":"owned","closed":false,"closed":true}"#), + ] { + assert_eq!( + matching_close( + &activity("browser.close", ToolActivityState::Completed, preview), + "owned" + ), + None + ); + } + } + + #[tokio::test] + async fn complete_replays_distinguish_explicit_and_automatic_cleanup() { + assert_eq!( + replay(vec![Ok(close(1)), Ok(update(2, None))], 2).await, + Some(true) + ); + assert_eq!( + replay(vec![Ok(update(1, None)), Ok(update(2, None))], 2).await, + Some(false) + ); + let maximum = (1..=MAX_UPDATES) + .map(|sequence| Ok(update(sequence, None))) + .collect(); + assert_eq!(replay(maximum, MAX_UPDATES).await, Some(false)); + } + + #[tokio::test] + async fn incomplete_or_inconsistent_replays_never_prove_absence() { + assert_eq!(replay(vec![Ok(close(1))], 2).await, None); + assert_eq!( + replay(vec![Ok(update(1, None)), Ok(update(2, None))], 1).await, + None + ); + assert_eq!(replay(vec![Ok(update(2, None))], 1).await, None); + let mut foreign = update(1, None); + foreign.run_id = "foreign".into(); + assert_eq!(replay(vec![Ok(foreign)], 1).await, None); + assert_eq!(replay(vec![Err(())], 1).await, None); + assert_eq!(replay(vec![], MAX_UPDATES + 1).await, None); + assert_eq!( + replay( + vec![Ok(update( + 1, + Some(activity( + "browser.close", + ToolActivityState::Completed, + None + )), + ))], + 1, + ) + .await, + None + ); + } + + #[tokio::test] + async fn replay_including_wait_for_exhaustion_is_time_bounded() { + let mut ready = stream::iter([Ok::<_, ()>(update(1, None))]); + assert_eq!( + classify(&mut ready, "run", 1, "owned", Instant::now()).await, + None + ); + let mut stalled = stream::pending::>(); + assert_eq!( + classify( + &mut stalled, + "run", + 1, + "owned", + Instant::now() + Duration::from_millis(10) + ) + .await, + None + ); + let first = stream::iter([Ok::<_, ()>(update(1, None))]); + let mut stalled_end = first.chain(stream::pending()); + assert_eq!( + classify( + &mut stalled_end, + "run", + 1, + "owned", + Instant::now() + Duration::from_millis(10) + ) + .await, + None + ); + } +} diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer/completed.rs b/apps/desktop/src-tauri/src/browser/windows_viewer/completed.rs new file mode 100644 index 000000000..bfab73621 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_viewer/completed.rs @@ -0,0 +1,70 @@ +//! Retain inspectable pixels only after canonical run completion and native detach. +use super::BrowserManager; +use crate::{browser::manager::error, dto::CommandErrorDto}; +use colossus_native_browser::BrowserView; +use std::sync::atomic::Ordering; + +impl BrowserManager { + pub(super) async fn retain_completed_agent( + &self, + scope: &str, + id: &str, + retain_pixels: bool, + ) -> Result<(), CommandErrorDto> { + let (view, _) = self.tab(id, scope)?; + // This is a hidden static copy of already authenticated, presented + // pixels. It has no Core lease, browser process, network or input path. + // A missing frame must not prevent acknowledged browser cleanup. + let pending = self + .lock()? + .tabs + .iter() + .find(|tab| tab.scope == scope && tab.dto.id == id) + .and_then(|tab| tab.pending_completed_view.clone()); + let snapshot = match pending { + // A prior complete canonical replay already admitted this hidden + // copy. Retrying its detach needs no new pixel extraction. + Some(snapshot) => Some(snapshot), + None if retain_pixels => view.completed_snapshot().await.ok().flatten(), + None => None, + }; + // Native detach can need a retry. Keep the hidden copy with its tab so + // a missing acknowledgment cannot consume the only accepted frame. + if let Some(tab) = self + .lock()? + .tabs + .iter_mut() + .find(|tab| tab.scope == scope && tab.dto.id == id) + { + tab.pending_completed_view.clone_from(&snapshot); + } + let page = snapshot.as_ref().and_then(BrowserView::completed_page); + view.close().await.map_err(|_| { + error("The agent browser is still closing. Its last view is not ready.") + })?; + let mut registry = self.lock()?; + let Some(tab) = registry + .tabs + .iter_mut() + .find(|tab| tab.scope == scope && tab.dto.id == id) + else { + return Ok(()); + }; + tab.presentation_epoch.fetch_add(1, Ordering::AcqRel); + tab.heartbeat = None; + tab.dto.completed_snapshot = snapshot.is_some(); + tab.view = snapshot.unwrap_or(BrowserView::Empty); + tab.pending_completed_view = None; + if let Some(page) = page { + tab.dto.page = page; + } + tab.dto.control = "completed"; + tab.dto.page.loading = false; + tab.dto.page.can_go_back = false; + tab.dto.page.can_go_forward = false; + tab.dto.error = None; + tab.dto.notice = None; + tab.dto.popup_url = None; + Ok(()) + } +} diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer/recovery.rs b/apps/desktop/src-tauri/src/browser/windows_viewer/recovery.rs new file mode 100644 index 000000000..f3a18481d --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_viewer/recovery.rs @@ -0,0 +1,25 @@ +//! Retire a failed presentation without discarding its inspectable tab identity. +use super::BrowserManager; +use crate::{browser::manager::error, dto::CommandErrorDto}; +use std::future::Future; + +impl BrowserManager { + pub(in crate::browser) async fn recover_agent_presentation( + &self, + scope: &str, + id: &str, + admission: impl Future, CommandErrorDto>>, + ) -> Result, CommandErrorDto> { + let (view, _) = self.tab(id, scope)?; + let _ = view.hide(); + view.close() + .await + .map_err(|_| error("The agent browser presentation is still closing."))?; + // A missing or rejected replacement is never authority to delete this + // tab. Terminal reconciliation still needs its session and last page. + admission.await + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/browser/windows_viewer/recovery/tests.rs b/apps/desktop/src-tauri/src/browser/windows_viewer/recovery/tests.rs new file mode 100644 index 000000000..a101da3b3 --- /dev/null +++ b/apps/desktop/src-tauri/src/browser/windows_viewer/recovery/tests.rs @@ -0,0 +1,130 @@ +use super::*; +use crate::browser::{ + dto::BrowserTabDto, + registry::{CRASHED_MESSAGE, Tab}, +}; +use colossus_native_browser::{BrowserView, NavigationPolicy, PageState}; +use std::sync::{Arc, atomic::AtomicU64}; + +fn manager() -> BrowserManager { + let manager = BrowserManager::default(); + { + let mut state = manager.lock().unwrap(); + state.scope = Some("workspace".into()); + state.selected.insert("workspace".into(), "agent".into()); + state.tabs.push(tab("human", "human", None)); + state.tabs.push(tab("agent", "agent", Some("run"))); + } + manager +} + +fn tab(id: &str, control: &'static str, run: Option<&str>) -> Tab { + Tab { + scope: "workspace".into(), + view: BrowserView::Empty, + pending_completed_view: None, + close_requested: false, + agent_run_id: run.map(str::to_owned), + policy: NavigationPolicy::default(), + heartbeat: None, + presentation_epoch: Arc::new(AtomicU64::new(1)), + dto: BrowserTabDto { + id: id.into(), + session_id: format!("session-{id}"), + conversation_id: Some("conversation".into()), + control, + completed_snapshot: false, + page: PageState { + url: "https://example.com/".into(), + title: "Example Domain".into(), + ..PageState::default() + }, + error: run.map(|_| CRASHED_MESSAGE.into()), + notice: None, + popup_url: None, + }, + } +} + +fn assert_retained(manager: &BrowserManager, control: &str) { + let state = manager.lock().unwrap(); + let snapshot = state.snapshot(); + assert_eq!(snapshot.selected_tab_id.as_deref(), Some("agent")); + assert_eq!(snapshot.tabs.len(), 2); + let agent = state.tabs.iter().find(|tab| tab.dto.id == "agent").unwrap(); + assert_eq!(agent.dto.page.url, "https://example.com/"); + assert_eq!(agent.dto.page.title, "Example Domain"); + assert_eq!(agent.dto.session_id, "session-agent"); + assert_eq!(agent.dto.control, control); + assert!(!agent.close_requested); +} + +#[tokio::test] +async fn completion_during_missing_replacement_retains_agent_tab_and_selection() { + let manager = manager(); + let admitted: Option<()> = manager + .recover_agent_presentation("workspace", "agent", async { + // Core finished after the earlier nonterminal GetRun. Its watch now + // admits no live page; canonical terminal replay proves no close. + assert_retained(&manager, "agent"); + manager + .settle_terminal_agent("workspace", "agent", Some(false)) + .await?; + Ok(None) + }) + .await + .unwrap(); + assert!(admitted.is_none()); + assert_retained(&manager, "completed"); + let state = manager.lock().unwrap(); + let agent = state.tabs.iter().find(|tab| tab.dto.id == "agent").unwrap(); + assert!(agent.dto.error.is_none()); + assert!(!agent.dto.completed_snapshot); +} + +#[tokio::test] +async fn rejected_replacement_preserves_last_page_for_terminal_reconciliation() { + let manager = manager(); + let admitted: Result, _> = manager + .recover_agent_presentation("workspace", "agent", async { + Err(error("The read-only admission is stale.")) + }) + .await; + assert!(admitted.is_err()); + assert_retained(&manager, "agent"); + manager + .settle_terminal_agent("workspace", "agent", None) + .await + .unwrap(); + assert_retained(&manager, "completed"); + // An explicit human close still removes the retained page normally. + manager.close("workspace", "agent").await.unwrap(); + let state = manager.lock().unwrap(); + assert_eq!(state.tabs.len(), 1); + assert_eq!( + state.selected.get("workspace").map(String::as_str), + Some("human") + ); +} + +#[tokio::test] +async fn proven_explicit_close_still_removes_a_tab_after_missing_recovery_admission() { + let manager = manager(); + let admitted: Option<()> = manager + .recover_agent_presentation("workspace", "agent", async { Ok(None) }) + .await + .unwrap(); + assert!(admitted.is_none()); + assert_retained(&manager, "agent"); + // The completed canonical replay proved an exact successful browser.close. + manager + .settle_terminal_agent("workspace", "agent", Some(true)) + .await + .unwrap(); + let state = manager.lock().unwrap(); + assert_eq!(state.tabs.len(), 1); + assert_eq!( + state.selected.get("workspace").map(String::as_str), + Some("human") + ); +} diff --git a/apps/desktop/src-tauri/src/bundle.rs b/apps/desktop/src-tauri/src/bundle.rs index 2e76a8894..d46255b6e 100644 --- a/apps/desktop/src-tauri/src/bundle.rs +++ b/apps/desktop/src-tauri/src/bundle.rs @@ -590,8 +590,8 @@ fn expected_release_channel() -> Result { match EXPECTED_RELEASE_CHANNEL { "stable" => Ok(ReleaseChannel::Stable), "developer_preview" => Ok(ReleaseChannel::DeveloperPreview), - // Validation-only bundles exercise packaging without becoming runnable. - "validation_only" | "development" => Err(integrity_error()), + // Validation-only bundles exercise packaging without becoming runnable; + // all other unrecognized channels remain equally untrusted. _ => Err(integrity_error()), } } diff --git a/apps/desktop/src-tauri/src/desktop_instance.rs b/apps/desktop/src-tauri/src/desktop_instance.rs index 917e657c3..6960b370c 100644 --- a/apps/desktop/src-tauri/src/desktop_instance.rs +++ b/apps/desktop/src-tauri/src/desktop_instance.rs @@ -1,10 +1,77 @@ //! Restore the owning Desktop process rather than starting another tray/runtime. + +#[cfg(windows)] +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +#[cfg(windows)] +use tauri::Manager as _; + +#[cfg(windows)] +struct InstanceState(Mutex>); + #[cfg(windows)] pub(crate) fn plugin() -> tauri::plugin::TauriPlugin { - // Invocation arguments never become commands or renderer input. - tauri_plugin_single_instance::init(|app, _args, _cwd| { - crate::status_bar::show_main_window(app); - }) + tauri::plugin::Builder::new("desktop-instance") + .setup(|app, _| { + // Startup's native guard stays held until build() completes. The + // callback only queues the existing restore operation: invocation + // arguments, paths and renderer input never cross this boundary. + let target = app.clone(); + let pending = Arc::new(AtomicBool::new(false)); + let instance = colossus_windows_native::DesktopInstance::install( + &app.config().identifier, + move || { + if pending.swap(true, Ordering::AcqRel) { + return true; + } + let window_app = target.clone(); + let completed = Arc::clone(&pending); + if target + .run_on_main_thread(move || { + crate::status_bar::show_main_window(&window_app); + completed.store(false, Ordering::Release); + }) + .is_err() + { + pending.store(false, Ordering::Release); + return false; + } + true + }, + )?; + match instance { + colossus_windows_native::DesktopInstance::Primary(owner) => { + app.manage(InstanceState(Mutex::new(Some(owner)))); + } + colossus_windows_native::DesktopInstance::Secondary => { + app.cleanup_before_exit(); + std::process::exit(0); + } + } + Ok(()) + }) + .on_event(|app, event| { + if !matches!(event, tauri::RunEvent::Exit) { + return; + } + let Some(state) = app.try_state::() else { + return; + }; + let owner = state.0.lock().ok().and_then(|mut value| value.take()); + if let Some(mut owner) = owner + && owner.stop().is_err() + { + // Keep custody after a failed stop; process exit ultimately + // closes these own-process resources. Never claim an ACK. + eprintln!("Colossus Desktop activation cleanup was not acknowledged."); + if let Ok(mut value) = state.0.lock() { + *value = Some(owner); + } + } + }) + .build() } #[cfg(all(test, windows))] diff --git a/apps/desktop/src-tauri/src/desktop_settings.rs b/apps/desktop/src-tauri/src/desktop_settings.rs index 9a13bffe1..0f92ff2c6 100644 --- a/apps/desktop/src-tauri/src/desktop_settings.rs +++ b/apps/desktop/src-tauri/src/desktop_settings.rs @@ -2588,7 +2588,7 @@ mod tests { #[cfg(windows)] let parent_guard = PrivateTestRoot::in_target("home-parent"); #[cfg(windows)] - let parent = parent_guard.path().to_path_buf(); + let parent = fs::canonicalize(parent_guard.path()).expect("canonical home parent"); #[cfg(not(windows))] let parent_guard = tempfile::tempdir().expect("home parent"); #[cfg(not(windows))] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d41ed724b..bf03c0d5a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,21 @@ +#[cfg(all( + feature = "windows-browser-functional-preview", + any(not(windows), not(target_arch = "x86_64")) +))] +compile_error!("Windows browser functional preview supports Windows x64 only"); +#[cfg(all( + feature = "windows-browser-functional-preview", + any( + feature = "embedded-chromium-preview", + feature = "macos-contained-browser-diagnostic", + feature = "windows-browser-development", + feature = "browser-test-bridge", + feature = "plugin-test-bridge", + feature = "approval-test-bridge" + ) +))] +compile_error!("the functional browser preview cannot include development or test bridges"); + #[cfg(all( feature = "macos-contained-browser-diagnostic", any(not(target_os = "macos"), not(debug_assertions)) @@ -203,8 +221,6 @@ pub fn run() { }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()); - #[cfg(any(target_os = "macos", windows))] - let application = application.plugin(tauri_plugin_notification::init()); let application = application .manage(state::AppState::default()) .manage(dictation::DictationState::default()) @@ -221,7 +237,8 @@ pub fn run() { #[cfg(any( feature = "embedded-chromium-preview", feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" ))] browser::contained::install(app.handle())?; #[cfg(windows)] diff --git a/apps/desktop/src-tauri/src/managed_configuration_commands.rs b/apps/desktop/src-tauri/src/managed_configuration_commands.rs index 9f655a6cf..047c5b176 100644 --- a/apps/desktop/src-tauri/src/managed_configuration_commands.rs +++ b/apps/desktop/src-tauri/src/managed_configuration_commands.rs @@ -1289,6 +1289,18 @@ fn risky_field_authority_changed( .iter() .map(|field| (field.field_id.as_str(), &field.value)) .collect::>(); + let browser_enabled = |fields: &BTreeMap<&str, &Value>, field: &str| { + fields + .get(field) + .and_then(|value| value.as_bool()) + .unwrap_or(true) + }; + if ["browser.enabled", "browser.publicWeb"] + .iter() + .any(|field| !browser_enabled(&before, field) && browser_enabled(&after, field)) + { + return true; + } // Removing a restriction can increase authority too. Treat changes to these // fields conservatively rather than inferring safety from empty/false values. RISKY_FIELDS @@ -1634,6 +1646,76 @@ fn effective_yaml( #[allow(clippy::too_many_lines)] fn field_descriptors() -> Vec { vec![ + descriptor( + "browser.enabled", + "Browser", + "Browser access", + "Allow browser tools and the bundled browser skill when a verified browser is installed. Disabling takes effect when the saved configuration is applied.", + "both", + "high", + "toggle", + false, + json!(true), + None, + None, + vec![], + ), + descriptor( + "browser.publicWeb", + "Browser", + "Load public websites normally", + "Load public HTTPS pages, redirects, images, and styles when workspace network policy permits. Turn off to restrict every request to the session’s listed origins.", + "both", + "high", + "toggle", + false, + json!(true), + None, + None, + vec![], + ), + descriptor( + "browser.maxSessions", + "Browser", + "Maximum browser sessions", + "Limit simultaneous temporary browser sessions in each workspace runtime.", + "both", + "low", + "number", + false, + json!(4), + Some(1), + Some(4), + vec![], + ), + descriptor( + "browser.maxTabs", + "Browser", + "Maximum browser tabs", + "Limit total tabs across browser sessions in each workspace runtime.", + "both", + "low", + "number", + false, + json!(8), + Some(1), + Some(8), + vec![], + ), + descriptor( + "browser.maxSnapshotNodes", + "Browser", + "Maximum snapshot nodes", + "Limit the page elements returned by each browser snapshot.", + "both", + "low", + "number", + false, + json!(1024), + Some(1), + Some(1024), + vec![], + ), descriptor( "network.maxRedirects", "Network", @@ -2322,6 +2404,21 @@ mod tests { settings } + #[test] + fn browser_disabling_can_apply_when_idle_but_reenabling_requires_apply() { + let before = resolved_for(&settings(), "space-one").expect("configuration"); + for field in ["browser.enabled", "browser.publicWeb"] { + let mut disabled = before.clone(); + disabled.field_overrides.push(FieldOverrideSetting { + field_id: field.into(), + value: json!(false), + }); + assert!(!requires_explicit_apply(&before, &disabled)); + assert!(requires_explicit_apply(&disabled, &before)); + assert!(!requires_explicit_apply(&disabled, &disabled)); + } + } + #[test] fn explicit_permission_choices_only_prompt_when_enabling_sensitive_telemetry() { let mut before = resolved_for(&settings(), "space-one").expect("configuration"); diff --git a/apps/desktop/src-tauri/src/managed_runtime/provider_catalog_tests.rs b/apps/desktop/src-tauri/src/managed_runtime/provider_catalog_tests.rs index 2ade5fc92..1db890e3c 100644 --- a/apps/desktop/src-tauri/src/managed_runtime/provider_catalog_tests.rs +++ b/apps/desktop/src-tauri/src/managed_runtime/provider_catalog_tests.rs @@ -77,16 +77,12 @@ impl CatalogTestHome { format!("signing-key:checkpoint-{instance}"), format!("journal-anchor:journal-{instance}"), ] { - let entry = keyring::Entry::new(RUNTIME_KEY_SERVICE, &account) - .expect("isolated runtime key"); - if let Err(error) = entry.delete_credential() { - assert!( - matches!(error, keyring::Error::NoEntry), - "cannot remove isolated catalog runtime key" - ); - } + let entry = + colossus_credentials::SystemCredentialEntry::new(RUNTIME_KEY_SERVICE, &account) + .expect("isolated runtime key"); + entry.delete().expect("remove isolated catalog runtime key"); assert!( - matches!(entry.get_secret(), Err(keyring::Error::NoEntry)), + entry.read().expect("read isolated runtime key").is_none(), "isolated catalog runtime key must be absent after cleanup" ); } @@ -170,13 +166,13 @@ fn native_catalog_recreated_home_does_not_reuse_previous_secure_anchor() { original_path.with_file_name(format!("{ROOT_PREFIX}retained-{}", uuid::Uuid::new_v4())); std::fs::rename(&original_path, &retained).expect("retain old home"); original.path = retained; - let anchor = keyring::Entry::new( + let anchor = colossus_credentials::SystemCredentialEntry::new( RUNTIME_KEY_SERVICE, &format!("journal-anchor:journal-{old_id}"), ) .unwrap(); assert!( - anchor.get_secret().is_ok(), + anchor.read().unwrap().is_some(), "old secure anchor survives the data reset" ); let mut recreated = CatalogTestHome::new(); @@ -193,7 +189,7 @@ fn native_catalog_recreated_home_does_not_reuse_previous_secure_anchor() { .expect("same-path fresh setup with old anchor present"); assert_eq!(cards[0].id, "fixture-model-retry"); assert!( - anchor.get_secret().is_ok(), + anchor.read().unwrap().is_some(), "setup must not delete the old journal anchor" ); }); diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index 356fbb12f..273bc3ca9 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -933,7 +933,10 @@ impl AppState { feature = "embedded-chromium-preview", any( feature = "macos-contained-browser-diagnostic", - feature = "windows-browser-development" + any( + feature = "windows-browser-development", + feature = "windows-browser-functional-preview" + ) ) ))] pub(crate) async fn managed_browser_for(&self, space_id: &str) -> Option { diff --git a/apps/desktop/src-tauri/src/status_bar.rs b/apps/desktop/src-tauri/src/status_bar.rs index bcc3cd776..fe81cbadf 100644 --- a/apps/desktop/src-tauri/src/status_bar.rs +++ b/apps/desktop/src-tauri/src/status_bar.rs @@ -16,6 +16,8 @@ use crate::dto::CommandErrorDto; mod notification_content; use notification_content::BackgroundNotificationContent; +#[cfg(any(target_os = "macos", windows))] +mod system_notification; const MAX_PINS: usize = 10; const MAX_RUN_ID_BYTES: usize = 128; @@ -184,8 +186,6 @@ pub(crate) fn notify_background( } #[cfg(any(target_os = "macos", windows))] { - use tauri_plugin_notification::NotificationExt as _; - let window = app .get_webview_window("main") .ok_or_else(status_bar_error)?; @@ -200,18 +200,13 @@ pub(crate) fn notify_background( return Ok(false); } let (title, body) = content.presentation(kind); - app.notification() - .builder() - .title(title) - .body(body) - .show() - .map_err(|_| { - CommandErrorDto::local_sanitized( - "notification_unavailable", - "Colossus could not show a system notification.", - true, - ) - })?; + system_notification::show(&app, &title, body).map_err(|()| { + CommandErrorDto::local_sanitized( + "notification_unavailable", + "Colossus could not show a system notification.", + true, + ) + })?; history.record_sent(run_id, kind, now); Ok(true) } diff --git a/apps/desktop/src-tauri/src/status_bar/system_notification.rs b/apps/desktop/src-tauri/src/status_bar/system_notification.rs new file mode 100644 index 000000000..efc061ab8 --- /dev/null +++ b/apps/desktop/src-tauri/src/status_bar/system_notification.rs @@ -0,0 +1,50 @@ +//! Host-only system notifications. Linux notifications remain unsupported. + +pub(super) fn show(app: &tauri::AppHandle, title: &str, body: &str) -> Result<(), ()> { + let mut notification = notify_rust::Notification::new(); + notification.summary(title).body(body).auto_icon(); + #[cfg(windows)] + { + let executable = tauri::utils::platform::current_exe().map_err(|_| ())?; + if registered_application(&executable) { + notification.app_id(&app.config().identifier); + } + } + #[cfg(target_os = "macos")] + notify_rust::set_application(if tauri::is_dev() { + "com.apple.Terminal" + } else { + &app.config().identifier + }) + .map_err(|_| ())?; + notification.show().map(|_| ()).map_err(|_| ()) +} + +#[cfg(windows)] +fn registered_application(executable: &std::path::Path) -> bool { + // Match the shipped notification adapter: unregistered Cargo executables use + // the native development fallback instead of an absent Start-menu identity. + executable.parent().is_some_and(|directory| { + !["debug", "release"] + .iter() + .any(|profile| directory.ends_with(std::path::Path::new("target").join(profile))) + }) +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn installed_notifications_keep_the_registered_identity() { + assert!(registered_application(Path::new( + r"C:\Users\fixture\AppData\Local\Colossus\Colossus Desktop.exe" + ))); + for profile in ["debug", "release"] { + assert!(!registered_application(Path::new(&format!( + r"D:\Colossus\target\{profile}\colossus-desktop.exe" + )))); + } + } +} diff --git a/apps/desktop/src-tauri/src/terminal_commands/native_tests.rs b/apps/desktop/src-tauri/src/terminal_commands/native_tests.rs index 474ff4cb9..16e82ffc5 100644 --- a/apps/desktop/src-tauri/src/terminal_commands/native_tests.rs +++ b/apps/desktop/src-tauri/src/terminal_commands/native_tests.rs @@ -358,9 +358,18 @@ async fn check_pane_viewport( main.window().set_focus().unwrap(); main.set_focus().unwrap(); tokio::time::sleep(Duration::from_millis(250)).await; + // A different foreground window must not blank the visible terminal pane. + let other = tauri::WindowBuilder::new(app, "terminal-focus-fixture") + .title("Terminal focus acceptance") + .inner_size(160.0, 100.0) + .build() + .map_err(|e| e.to_string())?; + other.set_focus().map_err(|e| e.to_string())?; + tokio::time::sleep(Duration::from_millis(250)).await; let active = colossus_native_browser::is_active(main) .await .map_err(|e| e.to_string())?; + assert!(!active, "fixture must transfer foreground focus"); // Compact layout starts directly below the shared 48px header. pane::terminal_pane_viewport( app.clone(), @@ -375,22 +384,24 @@ async fn check_pane_viewport( ) .await .map_err(|e| e.message)?; - assert_eq!( - app.state::().visible_lease(), - active + assert!(app.state::().visible_lease()); + assert!( + !colossus_native_browser::is_active(main) + .await + .map_err(|e| e.to_string())?, + "background terminal presentation stole focus" + ); + other.close().map_err(|e| e.to_string())?; + assert!( + (view + .position() + .unwrap() + .to_logical::(main.window().scale_factor().unwrap()) + .y + - 48.0) + .abs() + < 0.01 ); - if active { - assert!( - (view - .position() - .unwrap() - .to_logical::(main.window().scale_factor().unwrap()) - .y - - 48.0) - .abs() - < 0.01 - ); - } Ok(()) } diff --git a/apps/desktop/src-tauri/src/terminal_commands/pane.rs b/apps/desktop/src-tauri/src/terminal_commands/pane.rs index 673f2b870..b8809dc0d 100644 --- a/apps/desktop/src-tauri/src/terminal_commands/pane.rs +++ b/apps/desktop/src-tauri/src/terminal_commands/pane.rs @@ -1,7 +1,11 @@ //! Main may position a terminal view; only the terminal document can control PTYs. use super::{TERMINAL_WEBVIEW, shell_terminal_workspace, terminal_navigation_allowed}; use crate::{ - browser::{commands::require_controller, dto::BrowserRect, viewport::valid_rect}, + browser::{ + commands::require_controller, + dto::BrowserRect, + viewport::{valid_rect, window_can_present}, + }, dto::{CommandErrorDto, ShowTerminalInput}, state::AppState, terminal::{TerminalError, TerminalKind}, @@ -206,9 +210,7 @@ pub(crate) async fn terminal_pane_viewport( ) -> Result<(), CommandErrorDto> { require_controller(&caller)?; let window = app.get_window("main").ok_or_else(unavailable)?; - let active = colossus_native_browser::is_active(&caller) - .await - .unwrap_or(false); + let visible_window = window_can_present(&window); let size = window .inner_size() .map_err(|_| unavailable())? @@ -223,7 +225,7 @@ pub(crate) async fn terminal_pane_viewport( if view.window().label() != "main" { return Err(unavailable()); } - let visible = active + let visible = visible_window && data.scope == scope && state.terminal_enabled() && state.terminal_context_is_current(data.context); diff --git a/apps/desktop/src-tauri/src/uninstall/tests.rs b/apps/desktop/src-tauri/src/uninstall/tests.rs index 1bda9898a..7d53805d5 100644 --- a/apps/desktop/src-tauri/src/uninstall/tests.rs +++ b/apps/desktop/src-tauri/src/uninstall/tests.rs @@ -9,10 +9,12 @@ use std::os::windows::fs::OpenOptionsExt as _; static NATIVE_CREDENTIAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn fixture() -> (tempfile::TempDir, std::path::PathBuf) { - let parent = directories::BaseDirs::new() - .unwrap() - .data_local_dir() - .to_owned(); + // Packaged hosts can redirect new LocalAppData children into a different + // canonical hierarchy. Keep the fixture's retained ancestry stable. + let parent = std::env::var_os("USERPROFILE") + .map(std::path::PathBuf::from) + .filter(|path| path.is_absolute()) + .expect("absolute Windows user profile"); let guard = tempfile::Builder::new() .prefix("ColossusUninstallTest-") .tempdir_in(parent) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 014d5cb5e..43e861697 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -924,7 +924,9 @@ export default function App() { ? developmentFixtures().buildProviderRetryFixture() : FIXTURE_SCENARIO === "activity-comparison" ? FIXTURE_QUERY.get("imageViews") === "1" - ? developmentFixtures().buildImageViewFixture() + ? developmentFixtures().buildImageViewFixture( + FIXTURE_QUERY.get("browserScreenshot") === "1", + ) : developmentFixtures().buildActivityComparisonFixture() : FIXTURE_SCENARIO === "plan-workflow" ? developmentFixtures().buildPlanWorkflowFixture() @@ -2413,6 +2415,7 @@ export default function App() { return next; }); setSurface("work"); + setActiveSessionWorkspaceView("conversation"); dispatch({ type: "select_run", runId: null }); setRunLoadError(""); setActionError(null); @@ -6098,7 +6101,10 @@ export default function App() { if (FIXTURE_MODE) { if (FIXTURE_QUERY.get("imageViews") === "1") return Promise.resolve( - developmentFixtures().buildImageViewActivityFixture(request), + developmentFixtures().buildImageViewActivityFixture( + request, + FIXTURE_QUERY.get("browserScreenshot") === "1", + ), ); let liveActivityCount = 0; if (FIXTURE_ACTIVITY_LIVE && request.pageToken === "") { diff --git a/apps/desktop/src/activity-labels.test.ts b/apps/desktop/src/activity-labels.test.ts index a4a2a40e7..778d4ca17 100644 --- a/apps/desktop/src/activity-labels.test.ts +++ b/apps/desktop/src/activity-labels.test.ts @@ -12,6 +12,19 @@ function activity( } describe("activity labels", () => { + it("identifies browser actions separately from terminal commands", () => { + for (const tool of [ + "browser.snapshot", + "browser.back", + "browser.cdp", + "browser.close", + ]) { + expect(presentToolActivity(activity(tool)).kind).toBe("browser"); + } + expect(presentToolActivity(activity("command.run")).kind).not.toBe( + "browser", + ); + }); it("names filesystem work from released structured input", () => { expect( presentToolActivity( diff --git a/apps/desktop/src/activity-labels.ts b/apps/desktop/src/activity-labels.ts index ca88c2988..4ef827f2c 100644 --- a/apps/desktop/src/activity-labels.ts +++ b/apps/desktop/src/activity-labels.ts @@ -4,6 +4,7 @@ import type { ToolActivity, ToolActivityState } from "./types"; import { viewedImages } from "./viewed-images"; export type ActivityLabelKind = + | "browser" | "delegate" | "generic" | "image" @@ -439,7 +440,10 @@ export function presentToolActivity( }), }; default: - return { kind: "generic", title: genericToolTitle(activity) }; + return { + kind: activity.toolName.startsWith("browser.") ? "browser" : "generic", + title: genericToolTitle(activity), + }; } } diff --git a/apps/desktop/src/browser-api.ts b/apps/desktop/src/browser-api.ts index 032d921a1..5355b87ec 100644 --- a/apps/desktop/src/browser-api.ts +++ b/apps/desktop/src/browser-api.ts @@ -12,7 +12,10 @@ export interface BrowserTab { popupUrl: string | null; sessionId?: string; conversationId?: string | null; - control?: "human" | "agent" | "paused" | "unavailable"; + control?: + "human" | "agent" | "paused" | "completed" | "expired" | "unavailable"; + /** Desktop retains a read-only native frame after agent control ends. */ + completedSnapshot?: boolean; } export interface BrowserEngine { @@ -20,6 +23,7 @@ export interface BrowserEngine { | "embedded_chromium" | "contained_chromium" | "owned_chromium" + | "owned_chromium_functional_preview" | "webview2" | "webkit" | "unavailable"; diff --git a/apps/desktop/src/components/AgentInboxesView.tsx b/apps/desktop/src/components/AgentInboxesView.tsx index 0d83eb5ce..8cd0b46d3 100644 --- a/apps/desktop/src/components/AgentInboxesView.tsx +++ b/apps/desktop/src/components/AgentInboxesView.tsx @@ -22,20 +22,24 @@ export function AgentInboxesView({ : currentRunId; return ( <> - + {views.length > 1 && ( + + )} ; + effective: Map; + scope: "global" | "space"; + focusedFieldId?: string | null; + fieldElementId: (id: string) => string; + onChange: (id: string, value: unknown) => void; + onInherit: (id: string) => void; +}) { + function field(descriptor: ManagedFieldDescriptor) { + const overridden = Object.hasOwn(values, descriptor.id); + const inherited = effective.get(descriptor.id); + const value = overridden + ? values[descriptor.id] + : (inherited?.value ?? descriptor.defaultValue); + const source = overridden ? scope : (inherited?.source ?? "built_in"); + const id = fieldElementId(descriptor.id); + const toggle = descriptor.control === "toggle"; + return ( +
+
+ +

+ {descriptor.id === "browser.enabled" + ? "Let agents browse and interact with websites using a verified browser." + : descriptor.description} +

+
+ + {source === "built_in" + ? "Built-in default" + : source === "global" + ? "Global default" + : "Workspace override"} + + {overridden ? ( + + ) : null} +
+
+
+ {toggle ? ( + <> + + + onChange(descriptor.id, event.target.checked) + } + /> + + ) : ( + + onChange(descriptor.id, Number(event.target.value)) + } + /> + )} +
+
+ ); + } + return ( +
+
+ {descriptors.filter(({ control }) => control === "toggle").map(field)} +
+
+
+

Resource limits

+

Keep each workspace’s browser usage within these bounds.

+
+ {descriptors.filter(({ control }) => control !== "toggle").map(field)} +
+ +

+ {scope === "global" + ? "Save changes below. Active work finishes before updates apply. Re-enabling access requires Apply in Workspace settings." + : "Apply changes below to restart this workspace’s runtime and close temporary browser sessions. Inherit restores its accepted global defaults."} +

+
+ ); +} diff --git a/apps/desktop/src/components/DesktopToolSettings.tsx b/apps/desktop/src/components/DesktopToolSettings.tsx index 9d2ffe7e2..a4697b95b 100644 --- a/apps/desktop/src/components/DesktopToolSettings.tsx +++ b/apps/desktop/src/components/DesktopToolSettings.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Button, TextInput } from "@colossus/ui"; import { IconGitBranch, IconWorld, IconTerminal2 } from "@tabler/icons-react"; import { useDesktopPreferences } from "../DesktopPreferencesProvider"; import { validBrowserNewTabUrl } from "../desktop-preferences"; @@ -63,7 +64,7 @@ export function GitSettings() { ); } -export function BrowserSettings() { +export function BrowserSettings({ children }: { children?: React.ReactNode }) { const { browserNewTabUrl, updatePreferences } = useDesktopPreferences(); const [draft, setDraft] = useState(browserNewTabUrl); const [error, setError] = useState(""); @@ -84,16 +85,18 @@ export function BrowserSettings() {

Browser

- Choose the page opened by the new-tab button in Desktop's temporary - browser. + Set browser defaults for your workspaces. Each workspace can + override them.

+ {children}
{ @@ -106,13 +109,13 @@ export function BrowserSettings() { New-tab page - Leave blank for an empty tab. Use a full web address, including - for localhost previews. + Choose where new tabs open on this device. Leave blank for an + empty tab.
- - + {browserNewTabUrl ? ( - + ) : null}
{error ? ( @@ -151,8 +151,7 @@ export function BrowserSettings() {

) : null}

- The browser session remains temporary. This setting only affects new - tabs you open; it does not restore browsing history. + Browser sessions are temporary. Browsing history is not restored.

diff --git a/apps/desktop/src/components/ManagedSettingsPane.tsx b/apps/desktop/src/components/ManagedSettingsPane.tsx index ecda6e791..3133b09d4 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.tsx +++ b/apps/desktop/src/components/ManagedSettingsPane.tsx @@ -7,6 +7,7 @@ import { SetupPackagesPanel } from "./setup/SetupPackagesPanel"; import { useSetupPackages } from "./setup/useSetupPackages"; import { ImportedProviderActions } from "./setup/ImportedProviderActions"; import { SettingsFrame } from "./SettingsFrame"; +import { BrowserCapabilityFields } from "./BrowserCapabilityFields"; import { ModelRoleRouting } from "./ModelRoleRouting"; import { ModelRoutingOverview } from "./ModelRoutingOverview"; import { RuntimeProfileRow } from "./RuntimeProfileRow"; @@ -135,6 +136,7 @@ import { ProviderIcon } from "./ProviderIcon"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { presetProviderKind, + providerCredentialLabel, resetModelMetadata, selectCatalogModel, } from "../providerCatalog"; @@ -196,8 +198,9 @@ const DESKTOP_PAGES = [ { id: "browser", label: "Browser", - description: "Choose what opens in a new browser tab.", - keywords: "browser new tab home page url localhost preview", + description: "Control browser access, limits, and the new-tab page.", + keywords: + "browser enabled disabled sessions tabs snapshot skill new tab home page url localhost preview", }, { id: "terminal", @@ -234,6 +237,7 @@ type GlobalTab = | "defaults" | DesktopTab; type SpaceTab = + | "browser" | "cloud" | "runtime" | "providers" @@ -393,10 +397,13 @@ const GLOBAL_TABS: ReadonlyArray<{ { id: "credentials", label: "Credentials", group: "Global settings" }, { id: "mcp", label: "MCP", group: "Global settings" }, { id: "plugins", label: "Plugins", group: "Global settings" }, + { id: "browser", label: "Browser", group: "Global settings" }, { id: "search", label: "Search", group: "Global settings" }, { id: "telemetry", label: "Telemetry", group: "Global settings" }, { id: "defaults", label: "Defaults", group: "Global settings" }, - ...DESKTOP_PAGES.map(({ id, label }) => ({ id, label, group: "Desktop" })), + ...DESKTOP_PAGES.filter(({ id }) => id !== "browser").map( + ({ id, label }) => ({ id, label, group: "Desktop" }), + ), ]; const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ @@ -407,6 +414,7 @@ const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ { id: "routing", label: "Routing" }, { id: "mcp", label: "MCP" }, { id: "plugins", label: "Plugins" }, + { id: "browser", label: "Browser" }, { id: "access", label: "Access" }, { id: "sandbox", label: "Sandbox" }, { id: "search", label: "Search" }, @@ -417,9 +425,12 @@ const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ ]; export function managedFieldDestination(descriptor: ManagedFieldDescriptor): { - tab: "sandbox" | "research" | "advanced" | "runtime" | "plugins"; + tab: "browser" | "sandbox" | "research" | "advanced" | "runtime" | "plugins"; section: string | null; } { + if (descriptor.id.startsWith("browser.")) { + return { tab: "browser", section: null }; + } if (descriptor.id.startsWith("plugins.")) { return { tab: "plugins", section: null }; } @@ -527,6 +538,52 @@ const EMPTY_TELEMETRY_DRAFT: TelemetryEditorDraft = { }; const FIELD_DESCRIPTORS: ManagedFieldDescriptor[] = [ + toggleField( + "browser.enabled", + "Browser", + "Browser access", + "Allow browser tools and the bundled browser skill when a verified browser is installed. Disabling takes effect when the saved configuration is applied.", + true, + false, + ), + toggleField( + "browser.publicWeb", + "Browser", + "Load public websites normally", + "Load public HTTPS pages, redirects, images, and styles when workspace network policy permits. Turn off to restrict every request to the session’s listed origins.", + true, + false, + ), + field( + "browser.maxSessions", + "Browser", + "Maximum browser sessions", + "Limit simultaneous temporary browser sessions in each workspace runtime.", + 4, + false, + 1, + 4, + ), + field( + "browser.maxTabs", + "Browser", + "Maximum browser tabs", + "Limit total tabs across browser sessions in each workspace runtime.", + 8, + false, + 1, + 8, + ), + field( + "browser.maxSnapshotNodes", + "Browser", + "Maximum snapshot nodes", + "Limit the page elements returned by each browser snapshot.", + 1024, + false, + 1, + 1024, + ), field( "network.maxRedirects", "Network", @@ -2769,30 +2826,51 @@ export function ManagedSettingsPane({ setTelemetryEditor(null); } - async function createCredential() { - if (busy || !credentialLabel.trim()) return false; + async function createCredential(forProvider?: ProviderEditorDraft) { + const label = forProvider + ? providerCredentialLabel(forProvider.label) + : credentialLabel.trim(); + if (busy || !label) return false; const request = { expectedRevision: snapshot.globalConfiguration.revision, - label: credentialLabel.trim(), - kind: credentialKind, + label, + kind: forProvider ? ("api_key" as const) : credentialKind, + }; + const previousIds = new Set( + snapshot.globalConfiguration.credentials.map(({ id }) => id), + ); + let createdId: string | undefined; + const rememberCredential = (next: ManagedSettingsSnapshot) => { + createdId = next.globalConfiguration.credentials.find( + ({ id }) => !previousIds.has(id), + )?.id; + return next; }; const saved = await perform( - () => createManagedCredential(request), + async () => rememberCredential(await createManagedCredential(request)), () => - fixtureRevision((draft) => { - const id = crypto.randomUUID(); - draft.globalConfiguration.credentials.push({ - id, - label: request.label, - kind: request.kind, - backend: "desktop", - createdAtMs: Date.now(), - }); - draft.credentialAvailability[id] = "available"; - }), + rememberCredential( + fixtureRevision((draft) => { + const id = crypto.randomUUID(); + draft.globalConfiguration.credentials.push({ + id, + label: request.label, + kind: request.kind, + backend: "desktop", + createdAtMs: Date.now(), + }); + draft.credentialAvailability[id] = "available"; + }), + ), "Credential stored securely.", ); if (saved) setCredentialLabel(""); + if (saved && forProvider && createdId) { + const credentialId = createdId; + setProviderEditor((current) => + current === forProvider ? { ...current, credentialId } : current, + ); + } return saved; } @@ -2914,7 +2992,7 @@ export function ManagedSettingsPane({ }, [descriptors, query, snapshot]); const desktopPage = - scope === "global" + scope === "global" && globalTab !== "browser" ? DESKTOP_PAGES.find((page) => page.id === globalTab) : undefined; const externalTargets = desktop.targets.filter( @@ -3067,6 +3145,9 @@ export function ManagedSettingsPane({ providerEditor={providerEditor} setProviderEditor={setProviderEditor} onSaveProvider={() => void saveProvider()} + onAddProviderCredential={() => + providerEditor && void createCredential(providerEditor) + } onDeleteCatalogEntry={(kind, resourceId, trigger) => { setFailure(""); catalogDeleteTrigger.current = trigger; @@ -3281,6 +3362,7 @@ function GlobalSettingsBody({ providerEditor, setProviderEditor, onSaveProvider, + onAddProviderCredential, modelEditor, setModelEditor, onSaveModel, @@ -3338,6 +3420,7 @@ function GlobalSettingsBody({ providerEditor: ProviderEditorDraft | null; setProviderEditor: (draft: ProviderEditorDraft | null) => void; onSaveProvider: () => void; + onAddProviderCredential: () => void; modelEditor: ModelEditorDraft | null; setModelEditor: (draft: ModelEditorDraft | null) => void; onSaveModel: () => void; @@ -3791,9 +3874,37 @@ function GlobalSettingsBody({ ); } + if (tab === "browser") { + return ( + + + descriptor.id.startsWith("browser."), + )} + values={defaults.fields} + effective={new Map()} + scope="global" + fieldElementId={managedFieldElementId} + onChange={(id, value) => + setDefaults({ + ...defaults, + fields: { ...defaults.fields, [id]: value }, + }) + } + onInherit={(id) => { + const fields = { ...defaults.fields }; + delete fields[id]; + setDefaults({ ...defaults, fields }); + }} + /> + + ); + } if (tab === "defaults") { const defaultDescriptors = descriptors.filter( - (descriptor) => !descriptor.id.startsWith("plugins."), + (descriptor) => + !descriptor.id.startsWith("plugins.") && + !descriptor.id.startsWith("browser."), ); return (
@@ -4079,7 +4190,7 @@ function GlobalSettingsBody({ ? "Credential saved" : provider.credentialRequired ? "Needs API key" - : "No key required"} + : "No credential selected"} ), usage: `${models.length} ${models.length === 1 ? "model" : "models"}`, @@ -4111,7 +4222,7 @@ function GlobalSettingsBody({ ? "Credential unavailable" : provider.credentialRequired ? "Needs API key" - : "No key required"))} + : "No credential selected"))}
Request timeout
@@ -4169,6 +4280,7 @@ function GlobalSettingsBody({ onChange={setProviderEditor} onCancel={() => setProviderEditor(null)} onSave={onSaveProvider} + onAddCredential={onAddProviderCredential} /> ) : null} @@ -4991,6 +5103,7 @@ export function SpaceSettingsBody({ (descriptor) => !descriptor.advanced && !descriptor.id.startsWith("plugins.") && + !descriptor.id.startsWith("browser.") && !descriptor.id.startsWith("research."), )} values={draft.fields} @@ -5216,6 +5329,8 @@ export function SpaceSettingsBody({ } const filtered = descriptors.filter((descriptor) => { if (descriptor.id.startsWith("plugins.")) return false; + if (tab === "browser") return descriptor.id.startsWith("browser."); + if (descriptor.id.startsWith("browser.")) return false; if (tab === "sandbox") return descriptor.id.startsWith("sandbox."); if (tab === "research") return descriptor.id.startsWith("research."); return descriptor.advanced; @@ -5224,8 +5339,13 @@ export function SpaceSettingsBody({
-

Sparse Workspace overrides

+

Workspace overrides

{tab[0]!.toUpperCase() + tab.slice(1)}

+ {tab === "browser" ? ( +

+ Customize browser access and limits for this workspace. +

+ ) : null} {tab === "sandbox" ? (

Filesystem and network rules apply to isolated execution. Select @@ -5234,7 +5354,20 @@ export function SpaceSettingsBody({ ) : null}

- {tab === "advanced" ? ( + {tab === "browser" ? ( + + setDraft({ ...draft, fields: { ...draft.fields, [id]: value } }) + } + onInherit={(id) => removeDraftField(draft, setDraft, id)} + /> + ) : tab === "advanced" ? ( [...new Set(filtered.map((descriptor) => descriptor.section))] .sort() .map((section) => { @@ -6823,6 +6956,7 @@ function ProviderEditor({ onChange, onCancel, onSave, + onAddCredential, }: { draft: ProviderEditorDraft; credentials: ManagedSettingsSnapshot["globalConfiguration"]["credentials"]; @@ -6830,6 +6964,7 @@ function ProviderEditor({ onChange: (draft: ProviderEditorDraft) => void; onCancel: () => void; onSave: () => void; + onAddCredential: () => void; }) { const [readingIcon, setReadingIcon] = useState(false); const busy = externalBusy || readingIcon; @@ -6942,6 +7077,7 @@ function ProviderEditor({ label: draft.label || preset.label, profile: draft.profile || preset.id, credentialId: "", + credentialRequired: Boolean(preset.credentialEnv), }) } /> @@ -6998,37 +7134,57 @@ function ProviderEditor({

Choose a saved credential when this provider requires one.

-