From fc4045e64ed17275d273611a2315fa90a4bf82f6 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 7 Sep 2026 14:57:52 +0800 Subject: [PATCH 01/79] build(tauri): upgrade to tauri 2.11.5 and @tauri-apps/api 2.11.1 Pins tauri to 2.11 (2.11.1 ships the remote-origin ACL and .localhost origin fixes; 2.11.0 adds Webview::eval_with_callback and lets the on_new_window handler run on the main thread without Sync, which the built-in browser's popup routing relies on). tauri-runtime-wry 2.11.4 requires wry 0.55, so wry lands at 0.55.1. The JS side moves in step to @tauri-apps/api 2.11.1 / @tauri-apps/cli 2.11.4. --- package.json | 4 +- pnpm-lock.yaml | 118 ++++++------- src-tauri/Cargo.lock | 386 +++++++++++++++++++++++++++++++------------ src-tauri/Cargo.toml | 2 +- 4 files changed, 342 insertions(+), 168 deletions(-) diff --git a/package.json b/package.json index 11e95b8033..54551f8d07 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", @@ -91,7 +91,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "^2.11.4", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41c6115683..dfa61fe2ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^1.0.2 version: 1.0.2(react@19.2.4) '@tauri-apps/api': - specifier: ^2 - version: 2.10.1 + specifier: ^2.11.1 + version: 2.11.1 '@tauri-apps/plugin-dialog': specifier: ^2.6.0 version: 2.6.0 @@ -220,8 +220,8 @@ importers: specifier: ^4.1.18 version: 4.1.18 '@tauri-apps/cli': - specifier: ^2 - version: 2.10.0 + specifier: ^2.11.4 + version: 2.11.4 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.10.0(@testing-library/dom@10.4.1) @@ -2533,82 +2533,82 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} hasBin: true @@ -9005,74 +9005,74 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.10.0': + '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.10.0': + '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.0': + '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.0': + '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.0': + '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.0': + '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.10.0': + '@tauri-apps/cli@2.11.4': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@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/plugin-dialog@2.6.0': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-opener@2.5.3': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-process@2.3.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-updater@2.10.0': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-window-state@2.4.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@testing-library/dom@10.4.1': dependencies: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b698b40c4..b29660fccf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -605,6 +605,21 @@ dependencies = [ "which 4.4.2", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -1234,9 +1249,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", @@ -1371,6 +1386,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -1383,14 +1411,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.114", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -1663,12 +1697,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" @@ -1715,6 +1743,21 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.1", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1751,6 +1794,21 @@ 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" @@ -2013,6 +2071,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2595,7 +2659,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2681,10 +2745,20 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", - "markup5ever", + "markup5ever 0.14.1", "match_token", ] +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + [[package]] name = "http" version = "1.4.0" @@ -3355,10 +3429,10 @@ version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser", - "html5ever", + "cssparser 0.29.6", + "html5ever 0.29.1", "indexmap 2.13.0", - "selectors", + "selectors 0.24.0", ] [[package]] @@ -3544,9 +3618,20 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.1", + "web_atoms", ] [[package]] @@ -3688,9 +3773,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -3701,10 +3786,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3756,12 +3841,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3946,9 +4025,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -3962,17 +4041,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", "objc2-foundation", - "objc2-quartz-core", ] [[package]] @@ -3992,7 +4063,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.10.0", "objc2", "objc2-foundation", ] @@ -4032,28 +4102,25 @@ dependencies = [ ] [[package]] -name = "objc2-core-text" +name = "objc2-core-location" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "bitflags 2.10.0", "objc2", - "objc2-core-foundation", - "objc2-core-graphics", + "objc2-foundation", ] [[package]] -name = "objc2-core-video" +name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-io-surface", ] [[package]] @@ -4095,16 +4162,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -4130,25 +4187,33 @@ dependencies = [ ] [[package]] -name = "objc2-security" +name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", ] [[package]] -name = "objc2-ui-kit" +name = "objc2-user-notifications" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "bitflags 2.10.0", "objc2", - "objc2-core-foundation", "objc2-foundation", ] @@ -4164,8 +4229,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -4491,7 +4554,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -4504,6 +4566,17 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -4524,6 +4597,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.8.0" @@ -4554,6 +4637,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -4570,12 +4663,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.114", @@ -4617,6 +4710,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -5986,14 +6088,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser", + "cssparser 0.29.6", "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", - "servo_arc", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.10.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.2", + "servo_arc 0.4.3", "smallvec", ] @@ -6243,6 +6364,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6672,6 +6802,18 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" @@ -6684,6 +6826,18 @@ dependencies = [ "quote", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -6838,35 +6992,35 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.10.0", "block2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "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", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -6912,9 +7066,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -6963,9 +7117,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -6979,15 +7133,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.11+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -7012,9 +7165,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -7211,9 +7364,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -7236,9 +7389,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -7246,7 +7399,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -7263,24 +7415,26 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever", + "html5ever 0.29.1", "http", "infer", "json-patch", "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", + "plist", "proc-macro2", "quote", "regex", @@ -7356,6 +7510,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "termios" version = "0.2.2" @@ -7813,9 +7976,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -7827,10 +7990,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8266,6 +8429,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -8987,24 +9162,23 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.1" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", "dirs 6.0.0", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http", "javascriptcore-rs", "jni", - "kuchikiki", "libc", "ndk", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4dd42e88e2..46c2807ded 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,7 +56,7 @@ required-features = [] tauri-build = { version = "2", features = [], optional = true } [dependencies] -tauri = { version = "2", features = ["macos-private-api", "tray-icon"], optional = true } +tauri = { version = "2.11", features = ["macos-private-api", "tray-icon"], optional = true } tauri-plugin-opener = { version = "2", optional = true } tauri-plugin-dialog = { version = "2", optional = true } async-trait = "0.1" From 7c1377bf6fb2b250bad801bd480e232206fc6d0a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 7 Sep 2026 14:57:52 +0800 Subject: [PATCH 02/79] refactor(links): extract link classification and add the browser link decision function Moves the pure parsing helpers out of link-safety.tsx into lib/link-classify.ts (behaviour unchanged, re-exported for existing importers) and adds classifyLinkTarget as the single first step every link entrance runs: local path, OS handler, http(s), or an unsupported scheme that is still refused rather than handed to the OS. Adds lib/resolve-link-action.ts, the synchronous decision behind the built-in browser: classify, then terminal rules (host block rules and the remote-workspace loopback override, neither invertible), then the per-source preference or an explicit choice, then the modifier-key inversion, then placement (file column vs viewer drawer). lib/browser gains the URL helpers (loopback / private-network detection, dedupe normalization) and the per-key localStorage preferences the decision reads; nothing consumes the new modules yet. --- src/components/ai-elements/link-safety.tsx | 194 +---------- src/lib/browser/browser-prefs.test.ts | 114 +++++++ src/lib/browser/browser-prefs.ts | 185 +++++++++++ src/lib/browser/browser-url.test.ts | 118 +++++++ src/lib/browser/browser-url.ts | 133 ++++++++ src/lib/link-classify.test.ts | 87 +++++ src/lib/link-classify.ts | 231 ++++++++++++++ src/lib/resolve-link-action.test.ts | 355 +++++++++++++++++++++ src/lib/resolve-link-action.ts | 201 ++++++++++++ 9 files changed, 1437 insertions(+), 181 deletions(-) create mode 100644 src/lib/browser/browser-prefs.test.ts create mode 100644 src/lib/browser/browser-prefs.ts create mode 100644 src/lib/browser/browser-url.test.ts create mode 100644 src/lib/browser/browser-url.ts create mode 100644 src/lib/link-classify.test.ts create mode 100644 src/lib/link-classify.ts create mode 100644 src/lib/resolve-link-action.test.ts create mode 100644 src/lib/resolve-link-action.ts diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 5b3be0e434..55eb13a643 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -14,187 +14,19 @@ import { isHomeRelativePath } from "@/lib/file-open-target" import { isAbsoluteFilePath } from "@/lib/file-path-display" import { cn } from "@/lib/utils" -export interface LocalFileTarget { - path: string - line: number | null -} - -const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ -const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+\-.]*:/ -const ALLOWED_EXTERNAL_PROTOCOLS = new Set([ - "http:", - "https:", - "mailto:", - "tel:", -]) -// Protocols handled by the OS (mail client, dialer) rather than a browser -// page load. They must NOT be opened via `window.open(_, "_blank")` — most -// browsers leave behind an empty `about:blank` tab once the OS handler fires. -const OS_HANDLER_PROTOCOLS = new Set(["mailto:", "tel:"]) - -function normalizeSlashPath(path: string): string { - return path.replace(/\\/g, "/") -} - -/** Strip leading slash before Windows drive letter: /C:/foo → C:/foo */ -function stripLeadingSlashOnWindows(p: string): string { - if (p.startsWith("/") && WINDOWS_ABSOLUTE_PATH.test(p.slice(1))) { - return p.slice(1) - } - return p -} - -function decodeUriSafely(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function parseLineValue(raw: string | undefined): number | null { - if (!raw) return null - const line = Number.parseInt(raw, 10) - if (!Number.isFinite(line) || line <= 0) return null - return line -} - -function parseHashLine(hash: string): number | null { - const normalized = hash.startsWith("#") ? hash.slice(1) : hash - if (!normalized) return null - // `L` / `L-` / `L-L` (GitHub-style) — a range - // (e.g. the editor's "add selection" badge `#L10-25`) jumps to its start line. - return ( - parseLineValue(normalized.match(/^L(\d+)(?:-L?\d+)?$/i)?.[1]) ?? - parseLineValue(normalized.match(/^line=(\d+)$/i)?.[1]) ?? - parseLineValue(normalized.match(/^(\d+)$/)?.[1]) - ) -} - -function splitPathAndLine(rawPath: string): LocalFileTarget { - const trimmed = rawPath.trim() - const match = trimmed.match(/^(.*):(\d+)(?::\d+)?$/) - if (!match) { - return { path: trimmed, line: null } - } - - const maybePath = match[1] - if (!maybePath || maybePath.endsWith("://")) { - return { path: trimmed, line: null } - } - - const line = parseLineValue(match[2]) - if (!line) { - return { path: trimmed, line: null } - } - - return { path: maybePath, line } -} - -function isLocalPathLike(path: string): boolean { - // "//host/…" (forward slashes) is protocol-relative — a WEB url, not a - // local path. It must fall through to the external-URL route, never into - // local file IO. A "\\server\share" (backslashes) IS a local UNC path - // (a web url never uses backslashes) — the form remark-file-uri-links - // emits for file://server/share URIs. - return ( - (path.startsWith("/") && !path.startsWith("//")) || - path.startsWith("\\\\") || - path.startsWith("./") || - path.startsWith("../") || - path.startsWith("~/") || - WINDOWS_ABSOLUTE_PATH.test(path) - ) -} - -/** - * Parse a link target into a local file path + optional line, or null when it - * isn't a local file (a web url, an unsupported scheme, a bare-relative path). - * Exported so the transcript's file-badge action menu (message/ - * file-reference-actions.tsx) resolves a badge's path exactly the way a click - * on that badge resolves it. - */ -export function parseLocalFileTarget(rawUrl: string): LocalFileTarget | null { - const trimmed = rawUrl.trim() - if (!trimmed) return null - - if (trimmed.toLowerCase().startsWith("file://")) { - try { - const parsed = new URL(trimmed) - const rawPathname = decodeUriSafely(parsed.pathname) - // A non-empty host is a UNC authority (file://server/share/x) — - // preserve it as //server/share/x rather than dropping to /share/x. - const normalizedPathname = parsed.host - ? `//${parsed.host}${rawPathname}` - : stripLeadingSlashOnWindows(rawPathname) - const pathAndLine = splitPathAndLine(normalizedPathname) - if (!pathAndLine.path) return null - return { - path: normalizeSlashPath(pathAndLine.path), - line: parseHashLine(parsed.hash) ?? pathAndLine.line, - } - } catch { - return null - } - } - - if (URL_SCHEME.test(trimmed) && !WINDOWS_ABSOLUTE_PATH.test(trimmed)) { - return null - } - - // Split on raw # / ? before decoding so encoded `%23` / `%3F` inside the - // path don't get promoted to fragment/query separators (which would point - // the file opener at the wrong file). - const hashIndex = trimmed.indexOf("#") - const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "" - const beforeHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed - const queryIndex = beforeHash.indexOf("?") - const rawPathPart = - queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash - const decodedPath = decodeUriSafely(rawPathPart) - const pathAndLine = splitPathAndLine(decodedPath) - const normalizedPath = stripLeadingSlashOnWindows(pathAndLine.path) - if (!isLocalPathLike(normalizedPath)) return null - - return { - path: normalizeSlashPath(normalizedPath), - line: parseHashLine(rawHash) ?? pathAndLine.line, - } -} - -function parseExternalUrl(rawUrl: string): URL | null { - const trimmed = rawUrl.trim() - if (!trimmed) return null - - if (trimmed.startsWith("//")) { - // Protocol-relative: pin to https rather than the page protocol — a - // Tauri webview's own scheme (tauri://localhost) would otherwise - // classify these as an unsupported protocol, and the desktop opener - // capability only allows concrete http(s) URLs. - try { - return new URL(`https:${trimmed}`) - } catch { - return null - } - } - - if (!URL_SCHEME.test(trimmed) || WINDOWS_ABSOLUTE_PATH.test(trimmed)) { - return null - } - - try { - return new URL(trimmed) - } catch { - return null - } -} - -function getAllowedExternalProtocol(rawUrl: string): string | null { - const parsed = parseExternalUrl(rawUrl) - if (!parsed) return null - const protocol = parsed.protocol.toLowerCase() - return ALLOWED_EXTERNAL_PROTOCOLS.has(protocol) ? protocol : null -} +import { + OS_HANDLER_PROTOCOLS, + getAllowedExternalProtocol, + normalizeSlashPath, + parseLocalFileTarget, + type LocalFileTarget, +} from "@/lib/link-classify" + +// The parsing helpers live in `@/lib/link-classify` now (shared with the +// built-in browser's link decision and the terminal); re-exported here so the +// transcript-side importers keep their historical entry point. +export { parseLocalFileTarget } +export type { LocalFileTarget } /** * True when `window.open` actually opens something — i.e. a real browser. diff --git a/src/lib/browser/browser-prefs.test.ts b/src/lib/browser/browser-prefs.test.ts new file mode 100644 index 0000000000..517d1bcca7 --- /dev/null +++ b/src/lib/browser/browser-prefs.test.ts @@ -0,0 +1,114 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + DEFAULT_BROWSER_PREFS, + getBrowserPrefs, + markBrowserFirstOpenSeen, + resetBrowserPrefsForTests, + setAllDefaultLinkTargets, + setBrowserDevtools, + setBrowserSurfaceOverride, + setDefaultLinkTarget, + subscribeBrowserPrefs, + useBrowserPrefs, +} from "./browser-prefs" + +describe("browser prefs", () => { + beforeEach(() => { + resetBrowserPrefsForTests() + }) + + afterEach(() => { + resetBrowserPrefsForTests() + }) + + it("defaults every source to the built-in browser", () => { + expect(getBrowserPrefs()).toEqual(DEFAULT_BROWSER_PREFS) + }) + + it("returns the same snapshot object until something changes", () => { + const a = getBrowserPrefs() + expect(getBrowserPrefs()).toBe(a) + setDefaultLinkTarget("terminal", "system") + const b = getBrowserPrefs() + expect(b).not.toBe(a) + expect(b.defaultTarget.terminal).toBe("system") + expect(b.defaultTarget.transcript).toBe("builtin") + }) + + it("persists each setting under its own key", () => { + setDefaultLinkTarget("transcript", "system") + setBrowserDevtools(true) + setBrowserSurfaceOverride("window") + markBrowserFirstOpenSeen() + expect(localStorage.getItem("browser:default-target:transcript")).toBe( + "system" + ) + expect(localStorage.getItem("browser:devtools")).toBe("true") + expect(localStorage.getItem("browser:surface-override")).toBe("window") + expect(localStorage.getItem("browser:first-open-seen")).toBe("true") + expect(getBrowserPrefs()).toMatchObject({ + devtools: true, + surfaceOverride: "window", + firstOpenSeen: true, + }) + }) + + it("removes the surface override key when set back to auto", () => { + setBrowserSurfaceOverride("child") + setBrowserSurfaceOverride("auto") + expect(localStorage.getItem("browser:surface-override")).toBeNull() + expect(getBrowserPrefs().surfaceOverride).toBe("auto") + }) + + it("falls back to defaults for unknown stored values", () => { + localStorage.setItem("browser:default-target:terminal", "popup") + localStorage.setItem("browser:surface-override", "iframe") + expect(getBrowserPrefs().defaultTarget.terminal).toBe("builtin") + expect(getBrowserPrefs().surfaceOverride).toBe("auto") + }) + + it("setAllDefaultLinkTargets flips every source", () => { + setAllDefaultLinkTargets("system") + expect(Object.values(getBrowserPrefs().defaultTarget)).toEqual([ + "system", + "system", + "system", + "system", + "system", + ]) + }) + + it("notifies subscribers on same-window writes and cross-window storage events", () => { + const listener = vi.fn() + const unsubscribe = subscribeBrowserPrefs(listener) + setBrowserDevtools(true) + expect(listener).toHaveBeenCalledTimes(1) + + // Another window wrote directly to localStorage: the cache must drop. + localStorage.setItem("browser:devtools", "false") + window.dispatchEvent( + new StorageEvent("storage", { key: "browser:devtools" }) + ) + expect(listener).toHaveBeenCalledTimes(2) + expect(getBrowserPrefs().devtools).toBe(false) + + // Unrelated keys are ignored. + window.dispatchEvent(new StorageEvent("storage", { key: "other:key" })) + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + setBrowserDevtools(true) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it("useBrowserPrefs re-renders on change", () => { + const { result } = renderHook(() => useBrowserPrefs()) + expect(result.current.defaultTarget.toolCard).toBe("builtin") + act(() => { + setDefaultLinkTarget("toolCard", "system") + }) + expect(result.current.defaultTarget.toolCard).toBe("system") + }) +}) diff --git a/src/lib/browser/browser-prefs.ts b/src/lib/browser/browser-prefs.ts new file mode 100644 index 0000000000..f066e4010e --- /dev/null +++ b/src/lib/browser/browser-prefs.ts @@ -0,0 +1,185 @@ +// Built-in browser preferences. Persisted per key in localStorage (one key per +// setting rather than one JSON blob, so the settings window and the workspace +// window never clobber each other's writes); read through a cached snapshot so +// `useSyncExternalStore` gets a stable object between changes. Same shape as +// `office-preview-prefs.ts`: a same-window custom event plus the native +// cross-window `storage` event keep every reader live. + +import { useSyncExternalStore } from "react" + +/** Where a clicked address came from; each source carries its own default. */ +export type LinkSource = + | "transcript" + | "toolCard" + | "terminal" + | "editor" + | "notification" + +export const LINK_SOURCES: readonly LinkSource[] = [ + "transcript", + "toolCard", + "terminal", + "editor", + "notification", +] + +export type LinkTarget = "builtin" | "system" + +/** Runtime escape hatch over the compiled surface choice (§0.3 of the plan): + * lets a user on a platform whose child-webview path was never verified fall + * back to the owned-window surface without a rebuild. */ +export type SurfaceOverride = "auto" | "child" | "window" + +export interface BrowserPrefsSnapshot { + defaultTarget: Readonly> + devtools: boolean + surfaceOverride: SurfaceOverride + firstOpenSeen: boolean +} + +export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ + defaultTarget: Object.freeze({ + transcript: "builtin", + toolCard: "builtin", + terminal: "builtin", + editor: "builtin", + notification: "builtin", + }), + devtools: false, + surfaceOverride: "auto", + firstOpenSeen: false, +}) as BrowserPrefsSnapshot + +const KEY_PREFIX = "browser:" +const CHANGE_EVENT = "codeg:browser-prefs-changed" + +function targetKey(source: LinkSource): string { + return `${KEY_PREFIX}default-target:${source}` +} +const DEVTOOLS_KEY = `${KEY_PREFIX}devtools` +const SURFACE_KEY = `${KEY_PREFIX}surface-override` +const FIRST_OPEN_KEY = `${KEY_PREFIX}first-open-seen` + +function readRaw(key: string): string | null { + if (typeof window === "undefined") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function parseTarget(raw: string | null): LinkTarget | null { + return raw === "builtin" || raw === "system" ? raw : null +} + +function parseSurface(raw: string | null): SurfaceOverride | null { + return raw === "auto" || raw === "child" || raw === "window" ? raw : null +} + +function read(): BrowserPrefsSnapshot { + const defaultTarget = {} as Record + for (const source of LINK_SOURCES) { + defaultTarget[source] = + parseTarget(readRaw(targetKey(source))) ?? + DEFAULT_BROWSER_PREFS.defaultTarget[source] + } + return { + defaultTarget, + devtools: readRaw(DEVTOOLS_KEY) === "true", + surfaceOverride: + parseSurface(readRaw(SURFACE_KEY)) ?? + DEFAULT_BROWSER_PREFS.surfaceOverride, + firstOpenSeen: readRaw(FIRST_OPEN_KEY) === "true", + } +} + +let cached: BrowserPrefsSnapshot | null = null + +/** Current preferences. Cached until a write or a cross-window change. */ +export function getBrowserPrefs(): BrowserPrefsSnapshot { + if (cached === null) cached = read() + return cached +} + +function write(key: string, value: string | null): void { + if (typeof window === "undefined") return + try { + if (value === null) localStorage.removeItem(key) + else localStorage.setItem(key, value) + } catch { + /* quota / privacy mode: keep the in-memory value only */ + } + cached = null + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +export function setDefaultLinkTarget( + source: LinkSource, + target: LinkTarget +): void { + write(targetKey(source), target) +} + +/** The "always use the system browser" toast action: every source at once. */ +export function setAllDefaultLinkTargets(target: LinkTarget): void { + for (const source of LINK_SOURCES) write(targetKey(source), target) +} + +export function setBrowserDevtools(enabled: boolean): void { + write(DEVTOOLS_KEY, enabled ? "true" : "false") +} + +export function setBrowserSurfaceOverride(value: SurfaceOverride): void { + write(SURFACE_KEY, value === "auto" ? null : value) +} + +export function markBrowserFirstOpenSeen(): void { + write(FIRST_OPEN_KEY, "true") +} + +export function subscribeBrowserPrefs(listener: () => void): () => void { + if (typeof window === "undefined") return () => {} + const onChange = () => listener() + const onStorage = (event: StorageEvent) => { + // A `null` key is `localStorage.clear()`; anything under our prefix is ours. + if (event.key === null || event.key.startsWith(KEY_PREFIX)) { + cached = null + listener() + } + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", onStorage) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + window.removeEventListener("storage", onStorage) + } +} + +function getServerSnapshot(): BrowserPrefsSnapshot { + return DEFAULT_BROWSER_PREFS +} + +/** Reactive read; re-renders on same-window and cross-window changes. */ +export function useBrowserPrefs(): BrowserPrefsSnapshot { + return useSyncExternalStore( + subscribeBrowserPrefs, + getBrowserPrefs, + getServerSnapshot + ) +} + +/** Drop the cache and every stored key (tests only). */ +export function resetBrowserPrefsForTests(): void { + cached = null + if (typeof window === "undefined") return + try { + for (const source of LINK_SOURCES) + localStorage.removeItem(targetKey(source)) + localStorage.removeItem(DEVTOOLS_KEY) + localStorage.removeItem(SURFACE_KEY) + localStorage.removeItem(FIRST_OPEN_KEY) + } catch { + /* ignore */ + } +} diff --git a/src/lib/browser/browser-url.test.ts b/src/lib/browser/browser-url.test.ts new file mode 100644 index 0000000000..a8e59ae98c --- /dev/null +++ b/src/lib/browser/browser-url.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest" + +import { + displayHostPort, + hostnameOf, + isLoopbackHost, + isLoopbackOrPrivateUrl, + isPrivateNetworkHost, + normalizeUrlForDedupe, + originOf, + portOf, +} from "./browser-url" + +describe("isLoopbackHost", () => { + it.each([ + "localhost", + "LOCALHOST", + "app.localhost", + "127.0.0.1", + "127.1.2.3", + "::1", + "[::1]", + "::", + "0.0.0.0", + "::ffff:127.0.0.1", + ])("%s is loopback", (host) => { + expect(isLoopbackHost(host)).toBe(true) + }) + + it.each([ + "example.com", + "localhost.example.com", + "128.0.0.1", + "10.0.0.1", + "notlocalhost", + "", + ])("%s is not loopback", (host) => { + expect(isLoopbackHost(host)).toBe(false) + }) +}) + +describe("isPrivateNetworkHost", () => { + it.each([ + "10.0.0.1", + "10.255.255.255", + "172.16.0.1", + "172.31.255.1", + "192.168.1.20", + "169.254.169.254", + "fd12:3456::1", + "fc00::1", + "fe80::1%en0", + "[fe80::1]", + "mymac.local", + "::ffff:192.168.0.1", + ])("%s is private / link-local", (host) => { + expect(isPrivateNetworkHost(host)).toBe(true) + }) + + it.each([ + "172.15.0.1", + "172.32.0.1", + "11.0.0.1", + "192.169.0.1", + "8.8.8.8", + "2001:db8::1", + "example.com", + "127.0.0.1", + "localhost", + ])("%s is not private", (host) => { + expect(isPrivateNetworkHost(host)).toBe(false) + }) +}) + +describe("URL helpers", () => { + it("hostnameOf strips IPv6 brackets and lower-cases", () => { + expect(hostnameOf("http://[::1]:3000/x")).toBe("::1") + expect(hostnameOf("HTTPS://Example.COM/")).toBe("example.com") + expect(hostnameOf("not a url")).toBeNull() + }) + + it("isLoopbackOrPrivateUrl looks at the host only", () => { + expect(isLoopbackOrPrivateUrl("http://localhost:3000/api")).toBe(true) + expect(isLoopbackOrPrivateUrl("http://192.168.0.5:8080/")).toBe(true) + expect(isLoopbackOrPrivateUrl("https://github.com/x")).toBe(false) + expect(isLoopbackOrPrivateUrl("garbage")).toBe(false) + }) + + it("originOf returns null for opaque origins", () => { + expect(originOf("https://example.com:8443/a/b")).toBe( + "https://example.com:8443" + ) + expect(originOf("about:blank")).toBeNull() + expect(originOf("blob:null/abc")).toBeNull() + }) + + it("portOf resolves defaults per scheme", () => { + expect(portOf("http://example.com/")).toBe(80) + expect(portOf("https://example.com/")).toBe(443) + expect(portOf("http://example.com:3000/")).toBe(3000) + expect(portOf("mailto:x@y")).toBeNull() + }) + + it("normalizeUrlForDedupe drops the fragment and serializes", () => { + expect(normalizeUrlForDedupe("HTTP://Example.com/a#frag")).toBe( + "http://example.com/a" + ) + expect(normalizeUrlForDedupe("http://example.com")).toBe( + "http://example.com/" + ) + expect(normalizeUrlForDedupe("nope")).toBeNull() + }) + + it("displayHostPort keeps an explicit port and omits the default", () => { + expect(displayHostPort("http://localhost:3000/app")).toBe("localhost:3000") + expect(displayHostPort("https://example.com/")).toBe("example.com") + }) +}) diff --git a/src/lib/browser/browser-url.ts b/src/lib/browser/browser-url.ts new file mode 100644 index 0000000000..00cdcd62db --- /dev/null +++ b/src/lib/browser/browser-url.ts @@ -0,0 +1,133 @@ +// URL helpers for the built-in browser. Pure: no React, no transport, no DOM — +// every function takes a string and answers a question about it, so the link +// decision function and the Rust-side policy can be tested against the same +// tables. + +const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ + +function parseIpv4(host: string): [number, number, number, number] | null { + const m = host.match(IPV4) + if (!m) return null + const parts = m.slice(1, 5).map(Number) as [number, number, number, number] + return parts.every((n) => n >= 0 && n <= 255) ? parts : null +} + +/** `new URL(...).hostname` keeps the brackets around an IPv6 literal. */ +function stripBrackets(host: string): string { + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host +} + +/** Lower-cased hostname without IPv6 brackets, or null when `url` won't parse. */ +export function hostnameOf(url: string): string | null { + try { + const hostname = new URL(url).hostname + return hostname ? stripBrackets(hostname).toLowerCase() : null + } catch { + return null + } +} + +/** + * True for addresses that resolve to THIS machine: `localhost` and any + * `*.localhost` name, the whole `127/8` block, IPv6 `::1`, and the unspecified + * addresses `0.0.0.0` / `::` (dev servers print `http://0.0.0.0:3000` and a + * browser connects to that as local). IPv4-mapped IPv6 (`::ffff:127.0.0.1`) is + * unwrapped first. + */ +export function isLoopbackHost(hostname: string): boolean { + const host = stripBrackets(hostname.trim().toLowerCase()) + if (!host) return false + if (host === "localhost" || host.endsWith(".localhost")) return true + if (host === "::1" || host === "::" || host === "0.0.0.0") return true + const v4 = parseIpv4(host.startsWith("::ffff:") ? host.slice(7) : host) + return v4 !== null && v4[0] === 127 +} + +/** + * True for RFC 1918 / link-local / unique-local ranges and mDNS `*.local` + * names — hosts that only mean something on the network the machine is on. + * Loopback is NOT included; use `isLoopbackOrPrivateHost` for the union. + */ +export function isPrivateNetworkHost(hostname: string): boolean { + const host = stripBrackets(hostname.trim().toLowerCase()) + if (!host) return false + if (host.endsWith(".local")) return true + const v4 = parseIpv4(host.startsWith("::ffff:") ? host.slice(7) : host) + if (v4) { + const [a, b] = v4 + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ) + } + if (host.includes(":")) { + // fc00::/7 (unique local) and fe80::/10 (link local). + const first = host.split(":")[0] + if (first.length === 4) { + const n = Number.parseInt(first, 16) + if (Number.isNaN(n)) return false + return (n & 0xfe00) === 0xfc00 || (n & 0xffc0) === 0xfe80 + } + } + return false +} + +export function isLoopbackOrPrivateHost(hostname: string): boolean { + return isLoopbackHost(hostname) || isPrivateNetworkHost(hostname) +} + +export function isLoopbackOrPrivateUrl(url: string): boolean { + const hostname = hostnameOf(url) + return hostname !== null && isLoopbackOrPrivateHost(hostname) +} + +/** The URL's origin, or null when it is opaque (`about:blank`, `blob:` …). */ +export function originOf(url: string): string | null { + try { + const origin = new URL(url).origin + return origin && origin !== "null" ? origin : null + } catch { + return null + } +} + +/** Effective port: the explicit one, else the scheme default; null if unknown. */ +export function portOf(url: string): number | null { + try { + const parsed = new URL(url) + if (parsed.port) return Number(parsed.port) + if (parsed.protocol === "http:" || parsed.protocol === "ws:") return 80 + if (parsed.protocol === "https:" || parsed.protocol === "wss:") return 443 + return null + } catch { + return null + } +} + +/** + * Canonical form used to answer "is this page already open in a tab?": the + * WHATWG-serialized href with the fragment dropped (a different `#hash` is the + * same document). Null when the string is not a URL at all. + */ +export function normalizeUrlForDedupe(url: string): string | null { + try { + const parsed = new URL(url) + parsed.hash = "" + return parsed.href + } catch { + return null + } +} + +/** `host:port` as a user would type it — for the "this address lives on the + * remote host" hint; the default port is omitted. */ +export function displayHostPort(url: string): string | null { + try { + const parsed = new URL(url) + return parsed.host || null + } catch { + return null + } +} diff --git a/src/lib/link-classify.test.ts b/src/lib/link-classify.test.ts new file mode 100644 index 0000000000..61cfa3efaf --- /dev/null +++ b/src/lib/link-classify.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" + +import { classifyLinkTarget, parseLocalFileTarget } from "./link-classify" + +describe("classifyLinkTarget", () => { + it.each([ + ["/repo/src/app.ts", "/repo/src/app.ts", null], + ["/repo/src/app.ts:42", "/repo/src/app.ts", 42], + ["./src/app.ts", "./src/app.ts", null], + ["../src/app.ts", "../src/app.ts", null], + ["~/notes/todo.md", "~/notes/todo.md", null], + ["C:\\repo\\a.png", "C:/repo/a.png", null], + ["file:///repo/src/app.ts#L10", "/repo/src/app.ts", 10], + ["\\\\server\\share\\x.txt", "//server/share/x.txt", null], + ])("classifies %s as a local file", (input, path, line) => { + expect(classifyLinkTarget(input)).toEqual({ + kind: "file", + target: { path, line }, + }) + }) + + it.each([ + ["mailto:hi@example.com", "mailto:"], + ["tel:+15550100", "tel:"], + ["MAILTO:Upper@Example.com", "mailto:"], + ])("routes %s to the OS handler", (input, protocol) => { + expect(classifyLinkTarget(input)).toEqual({ + kind: "os-handler", + protocol, + url: input, + }) + }) + + it("keeps http(s) URLs as typed and exposes the parsed URL", () => { + const result = classifyLinkTarget(" https://example.com/a?b=1#c ") + expect(result.kind).toBe("http") + if (result.kind !== "http") throw new Error("unreachable") + expect(result.url).toBe("https://example.com/a?b=1#c") + expect(result.parsed.hostname).toBe("example.com") + }) + + it("pins a protocol-relative URL to https", () => { + const result = classifyLinkTarget("//example.com/docs") + expect(result).toMatchObject({ + kind: "http", + url: "https://example.com/docs", + }) + }) + + it.each([ + "vscode://file/repo/src/app.ts", + "javascript:alert(1)", + "data:text/html,x", + "ftp://example.com/file", + "tauri://localhost/", + "#section", + "src/main.rs", + "www.example.com", + ])("refuses %s as unsupported (never handed to the OS)", (input) => { + expect(classifyLinkTarget(input)).toEqual({ kind: "unsupported" }) + }) + + it("treats blank input as empty", () => { + expect(classifyLinkTarget(" ")).toEqual({ kind: "empty" }) + expect(classifyLinkTarget("")).toEqual({ kind: "empty" }) + }) +}) + +describe("parseLocalFileTarget (moved from link-safety, behaviour pinned)", () => { + it("does not mistake a protocol-relative web URL for a path", () => { + expect(parseLocalFileTarget("//example.com/x")).toBeNull() + }) + + it("keeps a UNC host from a file:// URI", () => { + expect(parseLocalFileTarget("file://server/share/x.txt")).toEqual({ + path: "//server/share/x.txt", + line: null, + }) + }) + + it("reads GitHub-style line ranges from the fragment", () => { + expect(parseLocalFileTarget("/repo/a.ts#L10-25")).toEqual({ + path: "/repo/a.ts", + line: 10, + }) + }) +}) diff --git a/src/lib/link-classify.ts b/src/lib/link-classify.ts new file mode 100644 index 0000000000..cf9551544b --- /dev/null +++ b/src/lib/link-classify.ts @@ -0,0 +1,231 @@ +// Pure link-target classification shared by every place a clicked address +// has to be routed: the transcript link-safety flow, the built-in browser's +// link decision function (`resolve-link-action.ts`) and the terminal's link +// handler. It answers ONE question — "what is this string?" — and never touches +// React, the transport layer or the DOM, so it is unit-testable in isolation +// and the classification cannot drift between entrances. +// +// The parsing helpers below were lifted verbatim from +// `components/ai-elements/link-safety.tsx`; `resource-kind.ts` mirrors the +// same regexes for the presentational type icon. Keep the three in step. + +export interface LocalFileTarget { + path: string + line: number | null +} + +const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ +const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+\-.]*:/ +export const ALLOWED_EXTERNAL_PROTOCOLS = new Set([ + "http:", + "https:", + "mailto:", + "tel:", +]) +// Protocols handled by the OS (mail client, dialer) rather than a browser +// page load. They must NOT be opened via `window.open(_, "_blank")` — most +// browsers leave behind an empty `about:blank` tab once the OS handler fires. +export const OS_HANDLER_PROTOCOLS = new Set(["mailto:", "tel:"]) + +export function normalizeSlashPath(path: string): string { + return path.replace(/\\/g, "/") +} + +/** Strip leading slash before Windows drive letter: /C:/foo → C:/foo */ +function stripLeadingSlashOnWindows(p: string): string { + if (p.startsWith("/") && WINDOWS_ABSOLUTE_PATH.test(p.slice(1))) { + return p.slice(1) + } + return p +} + +function decodeUriSafely(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function parseLineValue(raw: string | undefined): number | null { + if (!raw) return null + const line = Number.parseInt(raw, 10) + if (!Number.isFinite(line) || line <= 0) return null + return line +} + +function parseHashLine(hash: string): number | null { + const normalized = hash.startsWith("#") ? hash.slice(1) : hash + if (!normalized) return null + // `L` / `L-` / `L-L` (GitHub-style) — a range + // (e.g. the editor's "add selection" badge `#L10-25`) jumps to its start line. + return ( + parseLineValue(normalized.match(/^L(\d+)(?:-L?\d+)?$/i)?.[1]) ?? + parseLineValue(normalized.match(/^line=(\d+)$/i)?.[1]) ?? + parseLineValue(normalized.match(/^(\d+)$/)?.[1]) + ) +} + +function splitPathAndLine(rawPath: string): LocalFileTarget { + const trimmed = rawPath.trim() + const match = trimmed.match(/^(.*):(\d+)(?::\d+)?$/) + if (!match) { + return { path: trimmed, line: null } + } + + const maybePath = match[1] + if (!maybePath || maybePath.endsWith("://")) { + return { path: trimmed, line: null } + } + + const line = parseLineValue(match[2]) + if (!line) { + return { path: trimmed, line: null } + } + + return { path: maybePath, line } +} + +function isLocalPathLike(path: string): boolean { + // "//host/…" (forward slashes) is protocol-relative — a WEB url, not a + // local path. It must fall through to the external-URL route, never into + // local file IO. A "\\server\share" (backslashes) IS a local UNC path + // (a web url never uses backslashes) — the form remark-file-uri-links + // emits for file://server/share URIs. + return ( + (path.startsWith("/") && !path.startsWith("//")) || + path.startsWith("\\\\") || + path.startsWith("./") || + path.startsWith("../") || + path.startsWith("~/") || + WINDOWS_ABSOLUTE_PATH.test(path) + ) +} + +/** + * Parse a link target into a local file path + optional line, or null when it + * isn't a local file (a web url, an unsupported scheme, a bare-relative path). + * Exported so the transcript's file-badge action menu (message/ + * file-reference-actions.tsx) resolves a badge's path exactly the way a click + * on that badge resolves it. + */ +export function parseLocalFileTarget(rawUrl: string): LocalFileTarget | null { + const trimmed = rawUrl.trim() + if (!trimmed) return null + + if (trimmed.toLowerCase().startsWith("file://")) { + try { + const parsed = new URL(trimmed) + const rawPathname = decodeUriSafely(parsed.pathname) + // A non-empty host is a UNC authority (file://server/share/x) — + // preserve it as //server/share/x rather than dropping to /share/x. + const normalizedPathname = parsed.host + ? `//${parsed.host}${rawPathname}` + : stripLeadingSlashOnWindows(rawPathname) + const pathAndLine = splitPathAndLine(normalizedPathname) + if (!pathAndLine.path) return null + return { + path: normalizeSlashPath(pathAndLine.path), + line: parseHashLine(parsed.hash) ?? pathAndLine.line, + } + } catch { + return null + } + } + + if (URL_SCHEME.test(trimmed) && !WINDOWS_ABSOLUTE_PATH.test(trimmed)) { + return null + } + + // Split on raw # / ? before decoding so encoded `%23` / `%3F` inside the + // path don't get promoted to fragment/query separators (which would point + // the file opener at the wrong file). + const hashIndex = trimmed.indexOf("#") + const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "" + const beforeHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed + const queryIndex = beforeHash.indexOf("?") + const rawPathPart = + queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash + const decodedPath = decodeUriSafely(rawPathPart) + const pathAndLine = splitPathAndLine(decodedPath) + const normalizedPath = stripLeadingSlashOnWindows(pathAndLine.path) + if (!isLocalPathLike(normalizedPath)) return null + + return { + path: normalizeSlashPath(normalizedPath), + line: parseHashLine(rawHash) ?? pathAndLine.line, + } +} + +export function parseExternalUrl(rawUrl: string): URL | null { + const trimmed = rawUrl.trim() + if (!trimmed) return null + + if (trimmed.startsWith("//")) { + // Protocol-relative: pin to https rather than the page protocol — a + // Tauri webview's own scheme (tauri://localhost) would otherwise + // classify these as an unsupported protocol, and the desktop opener + // capability only allows concrete http(s) URLs. + try { + return new URL(`https:${trimmed}`) + } catch { + return null + } + } + + if (!URL_SCHEME.test(trimmed) || WINDOWS_ABSOLUTE_PATH.test(trimmed)) { + return null + } + + try { + return new URL(trimmed) + } catch { + return null + } +} + +export function getAllowedExternalProtocol(rawUrl: string): string | null { + const parsed = parseExternalUrl(rawUrl) + if (!parsed) return null + const protocol = parsed.protocol.toLowerCase() + return ALLOWED_EXTERNAL_PROTOCOLS.has(protocol) ? protocol : null +} + +/** + * The single classification every link entrance runs first. Order matters and + * mirrors what `useOpenLinkOrFile` has always done: a local path wins over any + * scheme parse (a Windows `C:\\…` would otherwise read as a URL scheme), then + * the protocol allow-list decides between the OS handler route, the http(s) + * route and "unsupported" — which stays a rejection: `vscode:`, `javascript:`, + * `data:`, `ftp:` and friends are never handed to the OS. + * + * `url` on the `os-handler` / `http` arms is the CANONICAL string to open: a + * protocol-relative `//host/path` becomes a concrete `https://host/path` (the + * desktop opener capability only allows http(s), and a raw `//…` would resolve + * against the webview's own scheme). + */ +export type LinkClassification = + | { kind: "empty" } + | { kind: "file"; target: LocalFileTarget } + | { kind: "os-handler"; protocol: string; url: string } + | { kind: "http"; url: string; parsed: URL } + | { kind: "unsupported" } + +export function classifyLinkTarget(rawUrl: string): LinkClassification { + const trimmed = rawUrl.trim() + if (!trimmed) return { kind: "empty" } + + const local = parseLocalFileTarget(trimmed) + if (local) return { kind: "file", target: local } + + const parsed = parseExternalUrl(trimmed) + if (!parsed) return { kind: "unsupported" } + const protocol = parsed.protocol.toLowerCase() + if (!ALLOWED_EXTERNAL_PROTOCOLS.has(protocol)) return { kind: "unsupported" } + + const url = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed + if (OS_HANDLER_PROTOCOLS.has(protocol)) { + return { kind: "os-handler", protocol, url } + } + return { kind: "http", url, parsed } +} diff --git a/src/lib/resolve-link-action.test.ts b/src/lib/resolve-link-action.test.ts new file mode 100644 index 0000000000..50a15fdbf8 --- /dev/null +++ b/src/lib/resolve-link-action.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "vitest" + +import { DEFAULT_BROWSER_PREFS, LINK_SOURCES } from "./browser/browser-prefs" +import { + matchHostRule, + resolveLinkAction, + type LinkSurface, + type ResolveLinkContext, +} from "./resolve-link-action" + +const desktopSurface: LinkSurface = { + builtinAvailable: true, + fileColumnVisible: true, + viewerHostAvailable: true, + remoteDesktop: false, +} + +const webSurface: LinkSurface = { + builtinAvailable: false, + fileColumnVisible: true, + viewerHostAvailable: false, + remoteDesktop: false, +} + +function ctx(overrides: Partial = {}): ResolveLinkContext { + return { + source: "transcript", + modifier: false, + surface: desktopSurface, + prefs: DEFAULT_BROWSER_PREFS, + ...overrides, + } +} + +const systemPrefs = { + defaultTarget: { + transcript: "system", + toolCard: "system", + terminal: "system", + editor: "system", + notification: "system", + }, +} as const + +describe("resolveLinkAction — classification passthrough", () => { + it("routes local paths to the file panel untouched", () => { + expect(resolveLinkAction("/repo/a.ts:12", ctx())).toEqual({ + kind: "file", + target: { path: "/repo/a.ts", line: 12 }, + }) + }) + + it("routes mailto/tel to the OS handler", () => { + expect(resolveLinkAction("mailto:x@y.z", ctx())).toEqual({ + kind: "os-handler", + protocol: "mailto:", + url: "mailto:x@y.z", + }) + }) + + it.each(["vscode://file/x", "javascript:alert(1)", "src/main.rs"])( + "still rejects %s (unknown scheme is never handed to the OS)", + (url) => { + expect(resolveLinkAction(url, ctx())).toEqual({ + kind: "reject", + reason: "unsupported-scheme", + url, + }) + } + ) + + it("rejects empty input", () => { + expect(resolveLinkAction(" ", ctx())).toMatchObject({ + kind: "reject", + reason: "empty", + }) + }) + + it("canonicalizes a protocol-relative URL before deciding", () => { + expect(resolveLinkAction("//example.com/x", ctx())).toMatchObject({ + kind: "builtin", + url: "https://example.com/x", + }) + }) +}) + +describe("resolveLinkAction — base target and preferences", () => { + it.each(LINK_SOURCES)("defaults %s to the built-in browser", (source) => { + expect(resolveLinkAction("https://example.com", ctx({ source }))).toEqual({ + kind: "builtin", + url: "https://example.com", + placement: "tab", + remoteOverride: false, + }) + }) + + it.each(LINK_SOURCES)( + "honours a per-source system preference for %s", + (source) => { + const prefs = { + defaultTarget: { + ...DEFAULT_BROWSER_PREFS.defaultTarget, + [source]: "system" as const, + }, + } + expect( + resolveLinkAction("https://example.com", ctx({ source, prefs })) + ).toEqual({ kind: "system", url: "https://example.com" }) + // Other sources are unaffected. + const other = LINK_SOURCES.find((s) => s !== source)! + expect( + resolveLinkAction("https://example.com", ctx({ source: other, prefs })) + ).toMatchObject({ kind: "builtin" }) + } + ) + + it("is always system when no built-in browser is available (web mode)", () => { + expect( + resolveLinkAction("https://example.com", ctx({ surface: webSurface })) + ).toEqual({ kind: "system", url: "https://example.com" }) + expect( + resolveLinkAction( + "https://example.com", + ctx({ surface: webSurface, modifier: true }) + ) + ).toEqual({ kind: "system", url: "https://example.com" }) + expect( + resolveLinkAction( + "https://example.com", + ctx({ surface: webSurface, forceTarget: "builtin" }) + ) + ).toEqual({ kind: "system", url: "https://example.com" }) + }) +}) + +describe("resolveLinkAction — modifier inversion", () => { + it("inverts builtin → system", () => { + expect( + resolveLinkAction("https://example.com", ctx({ modifier: true })) + ).toEqual({ kind: "system", url: "https://example.com" }) + }) + + it("inverts system → builtin", () => { + expect( + resolveLinkAction( + "https://example.com", + ctx({ modifier: true, prefs: systemPrefs }) + ) + ).toMatchObject({ kind: "builtin", remoteOverride: false }) + }) + + it("applies to every source the same way", () => { + for (const source of LINK_SOURCES) { + expect( + resolveLinkAction( + "https://example.com", + ctx({ source, modifier: true }) + ) + ).toMatchObject({ kind: "system" }) + } + }) +}) + +describe("resolveLinkAction — explicit choice (context menu)", () => { + it("forceTarget replaces the preference", () => { + expect( + resolveLinkAction("https://example.com", ctx({ forceTarget: "system" })) + ).toEqual({ kind: "system", url: "https://example.com" }) + expect( + resolveLinkAction( + "https://example.com", + ctx({ forceTarget: "builtin", prefs: systemPrefs }) + ) + ).toMatchObject({ kind: "builtin" }) + }) + + it("forceTarget ignores the modifier", () => { + expect( + resolveLinkAction( + "https://example.com", + ctx({ forceTarget: "system", modifier: true }) + ) + ).toEqual({ kind: "system", url: "https://example.com" }) + }) +}) + +describe("resolveLinkAction — host rules", () => { + const hostRules = [ + { pattern: "blocked.example", action: "block" as const }, + { pattern: "*.corp.example:8443", action: "system" as const }, + { pattern: "*.corp.example", action: "builtin" as const }, + { pattern: "localhost", action: "builtin" as const }, + ] + + it("block is terminal: not invertible, not forceable", () => { + for (const extra of [ + {}, + { modifier: true }, + { forceTarget: "system" as const }, + { forceTarget: "builtin" as const }, + ]) { + expect( + resolveLinkAction( + "https://blocked.example/path", + ctx({ hostRules, ...extra }) + ) + ).toEqual({ + kind: "reject", + reason: "blocked-host", + url: "https://blocked.example/path", + }) + } + }) + + it("a builtin/system rule is the base target and stays invertible", () => { + expect( + resolveLinkAction( + "https://wiki.corp.example/", + ctx({ hostRules, prefs: systemPrefs }) + ) + ).toMatchObject({ kind: "builtin" }) + expect( + resolveLinkAction( + "https://wiki.corp.example/", + ctx({ hostRules, prefs: systemPrefs, modifier: true }) + ) + ).toMatchObject({ kind: "system" }) + }) + + it("first matching rule wins and ports pin a rule", () => { + expect( + resolveLinkAction("https://sso.corp.example:8443/", ctx({ hostRules })) + ).toMatchObject({ kind: "system" }) + expect( + resolveLinkAction("https://sso.corp.example/", ctx({ hostRules })) + ).toMatchObject({ kind: "builtin" }) + }) + + it("matchHostRule: wildcard semantics", () => { + const rules = [{ pattern: "*.example.com", action: "block" as const }] + expect( + matchHostRule(rules, new URL("https://a.example.com/")) + ).not.toBeNull() + expect( + matchHostRule(rules, new URL("https://a.b.example.com/")) + ).not.toBeNull() + expect(matchHostRule(rules, new URL("https://example.com/"))).toBeNull() + expect(matchHostRule(rules, new URL("https://notexample.com/"))).toBeNull() + expect( + matchHostRule([{ pattern: "*", action: "system" }], new URL("http://x/")) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "[::1]:3000", action: "builtin" }], + new URL("http://[::1]:3000/") + ) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "example.com:443", action: "builtin" }], + new URL("https://EXAMPLE.com/") + ) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "example.com:80", action: "builtin" }], + new URL("https://example.com/") + ) + ).toBeNull() + }) +}) + +describe("resolveLinkAction — remote workspace override", () => { + const remote: LinkSurface = { ...desktopSurface, remoteDesktop: true } + + it.each([ + "http://localhost:3000/", + "http://127.0.0.1:8080/x", + "http://[::1]:5173/", + "http://192.168.1.10:3000/", + "http://api.internal.local/", + ])("%s in a remote window opens built-in with the remote flag", (url) => { + expect(resolveLinkAction(url, ctx({ surface: remote }))).toEqual({ + kind: "builtin", + url, + placement: "tab", + remoteOverride: true, + }) + }) + + it("cannot be inverted or forced to the system browser", () => { + expect( + resolveLinkAction( + "http://localhost:3000/", + ctx({ surface: remote, modifier: true }) + ) + ).toMatchObject({ kind: "builtin", remoteOverride: true }) + expect( + resolveLinkAction( + "http://localhost:3000/", + ctx({ surface: remote, forceTarget: "system", prefs: systemPrefs }) + ) + ).toMatchObject({ kind: "builtin", remoteOverride: true }) + }) + + it("does not apply to public hosts in a remote window", () => { + expect( + resolveLinkAction( + "https://github.com/", + ctx({ surface: remote, prefs: systemPrefs }) + ) + ).toEqual({ kind: "system", url: "https://github.com/" }) + }) + + it("falls back to the ordinary rules when no built-in browser exists", () => { + expect( + resolveLinkAction( + "http://localhost:3000/", + ctx({ surface: { ...remote, builtinAvailable: false } }) + ) + ).toEqual({ kind: "system", url: "http://localhost:3000/" }) + }) +}) + +describe("resolveLinkAction — placement", () => { + it("uses the file column when it is visible", () => { + expect(resolveLinkAction("https://example.com", ctx())).toMatchObject({ + placement: "tab", + }) + }) + + it("uses the viewer drawer on a full-page route", () => { + expect( + resolveLinkAction( + "https://example.com", + ctx({ surface: { ...desktopSurface, fileColumnVisible: false } }) + ) + ).toMatchObject({ placement: "drawer" }) + }) + + it("falls back to the column when no viewer host exists", () => { + expect( + resolveLinkAction( + "https://example.com", + ctx({ + surface: { + ...desktopSurface, + fileColumnVisible: false, + viewerHostAvailable: false, + }, + }) + ) + ).toMatchObject({ placement: "tab" }) + }) +}) diff --git a/src/lib/resolve-link-action.ts b/src/lib/resolve-link-action.ts new file mode 100644 index 0000000000..ba1c2294e4 --- /dev/null +++ b/src/lib/resolve-link-action.ts @@ -0,0 +1,201 @@ +// The ONE decision function behind every http(s) click in the app: transcript +// links, tool cards, the terminal, notifications. Synchronous and pure — the +// caller supplies a snapshot of preferences and of the surface it sits in, and +// gets back an action to execute INSIDE THE CLICK'S OWN CALL STACK (a system +// target must reach `window.open` / `openUrl` before the user gesture expires; +// WebKit's popup blocker swallows anything a microtask later, see issue #410). +// +// Order, and why it is fixed: +// 1. classify — identical to what `link-safety.tsx` has always done, so a +// local path still opens the file panel, `mailto:`/`tel:` still go to the +// OS, and an unknown scheme (`vscode:`, `javascript:` …) is still refused +// rather than handed to the OS. +// 2. terminal rules — a `block` host rule, or a loopback/private address seen +// from a window bound to a REMOTE codeg-server. These cannot be inverted +// by the modifier key: flipping a remote `localhost:3000` to the system +// browser would only ever hit the local machine's loopback. +// 3. base target — an explicit menu choice, else a host rule, else the +// per-source preference; without a built-in browser it is always `system`. +// 4. modifier — ⌘ (macOS) / Ctrl (elsewhere) inverts an ordinary preference. +// 5. placement — the file column when it is on screen, else the transcript's +// own viewer drawer (full-page routes), else the column anyway. + +import { isLoopbackOrPrivateHost } from "@/lib/browser/browser-url" +import type { + BrowserPrefsSnapshot, + LinkSource, + LinkTarget, +} from "@/lib/browser/browser-prefs" +import { classifyLinkTarget, type LocalFileTarget } from "@/lib/link-classify" + +export interface HostRule { + /** Hostname, `*.suffix`, or `*`; an optional `:port` pins the port. */ + pattern: string + action: LinkTarget | "block" +} + +export interface LinkSurface { + /** `browser_capabilities().available` on desktop; false in web mode. */ + builtinAvailable: boolean + /** The workspace file column is on screen (conversations route). */ + fileColumnVisible: boolean + /** A session viewer host can render the file/browser drawer instead. */ + viewerHostAvailable: boolean + /** The window is bound to a remote codeg-server (`isRemoteDesktopMode()`). */ + remoteDesktop: boolean +} + +export interface ResolveLinkContext { + source: LinkSource + /** Primary modifier held during the gesture: ⌘ on macOS, Ctrl elsewhere. */ + modifier: boolean + surface: LinkSurface + prefs: Pick + hostRules?: readonly HostRule[] + /** An explicit user choice (context menu). Replaces the preference and + * ignores the modifier, but still yields to the terminal rules. */ + forceTarget?: LinkTarget +} + +export type LinkAction = + | { kind: "file"; target: LocalFileTarget } + | { kind: "os-handler"; url: string; protocol: string } + | { + kind: "reject" + reason: "empty" | "unsupported-scheme" | "blocked-host" + url: string + } + | { kind: "system"; url: string } + | { + kind: "builtin" + url: string + placement: "tab" | "drawer" + /** Chosen by the remote-workspace terminal rule: the address is loopback + * or private and only reachable from the remote host. */ + remoteOverride: boolean + } + +function effectivePort(parsed: URL): string { + if (parsed.port) return parsed.port + return parsed.protocol === "https:" ? "443" : "80" +} + +function splitPattern(pattern: string): { host: string; port: string | null } { + const trimmed = pattern.trim().toLowerCase() + // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows them. + if (trimmed.startsWith("[")) { + const close = trimmed.indexOf("]") + if (close === -1) return { host: trimmed, port: null } + const host = trimmed.slice(1, close) + const rest = trimmed.slice(close + 1) + return { host, port: rest.startsWith(":") ? rest.slice(1) : null } + } + const colon = trimmed.lastIndexOf(":") + if (colon !== -1 && /^\d+$/.test(trimmed.slice(colon + 1))) { + return { host: trimmed.slice(0, colon), port: trimmed.slice(colon + 1) } + } + return { host: trimmed, port: null } +} + +function hostMatches(patternHost: string, hostname: string): boolean { + if (patternHost === "*") return true + if (patternHost.startsWith("*.")) { + const suffix = patternHost.slice(1) // ".example.com" + return hostname.endsWith(suffix) && hostname.length > suffix.length + } + return patternHost === hostname +} + +/** First matching rule wins; hostnames compare case-insensitively. */ +export function matchHostRule( + rules: readonly HostRule[] | undefined, + parsed: URL +): HostRule | null { + if (!rules || rules.length === 0) return null + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() + const port = effectivePort(parsed) + for (const rule of rules) { + const { host, port: rulePort } = splitPattern(rule.pattern) + if (!host) continue + if (!hostMatches(host, hostname)) continue + if (rulePort !== null && rulePort !== port) continue + return rule + } + return null +} + +function invert(target: LinkTarget): LinkTarget { + return target === "builtin" ? "system" : "builtin" +} + +export function resolveLinkAction( + rawUrl: string, + ctx: ResolveLinkContext +): LinkAction { + // 1. classify + const classified = classifyLinkTarget(rawUrl) + switch (classified.kind) { + case "empty": + return { kind: "reject", reason: "empty", url: rawUrl } + case "file": + return { kind: "file", target: classified.target } + case "os-handler": + return { + kind: "os-handler", + url: classified.url, + protocol: classified.protocol, + } + case "unsupported": + return { kind: "reject", reason: "unsupported-scheme", url: rawUrl } + case "http": + break + } + const { url, parsed } = classified + const { surface } = ctx + + // 2. terminal rules + const rule = matchHostRule(ctx.hostRules, parsed) + if (rule?.action === "block") { + return { kind: "reject", reason: "blocked-host", url } + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, "") + if ( + surface.remoteDesktop && + surface.builtinAvailable && + isLoopbackOrPrivateHost(hostname) + ) { + return { + kind: "builtin", + url, + placement: placementFor(surface), + remoteOverride: true, + } + } + + // 3. base target + let target: LinkTarget + if (!surface.builtinAvailable) { + target = "system" + } else if (ctx.forceTarget) { + target = ctx.forceTarget + } else { + // `block` returned above, so a surviving rule carries a plain target. + target = rule?.action ?? ctx.prefs.defaultTarget[ctx.source] ?? "builtin" + // 4. modifier + if (ctx.modifier) target = invert(target) + } + + // 5. placement + if (target === "system") return { kind: "system", url } + return { + kind: "builtin", + url, + placement: placementFor(surface), + remoteOverride: false, + } +} + +function placementFor(surface: LinkSurface): "tab" | "drawer" { + if (surface.fileColumnVisible) return "tab" + return surface.viewerHostAvailable ? "drawer" : "tab" +} From 9b17313182d3fd36ec9245fd5830ac1d52683962 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 7 Sep 2026 15:35:33 +0800 Subject: [PATCH 03/79] feat(browser): scaffold Rust-owned browser tabs on a wry child surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the src-tauri/src/browser module: wire types mirrored by the frontend, the scheme allow-list policy, a tab registry, and two surfaces — an embedded child webview for macOS / Windows and an owned window for Linux and fallbacks — plus the first browser_* commands (capabilities, open, close, bounds, visibility, navigate, reload, state, list) and the browser://state / browser://closed events. The embedded surface is built straight through tauri-runtime-wry's re-exported wry (WebViewBuilder::build_as_child on the owner window) rather than tauri's Window::add_child. A tauri child flips the owner's is_webview_window() to false, after which get_webview_window("main") returns None and every command taking a tauri::WebviewWindow argument fails; wry-built children are invisible to tauri, so the workspace window keeps behaving as before and no `unstable` feature is needed. wry webviews are not Send, so instances live in a main-thread thread-local behind a cloneable ChildHandle that hops to the main thread per operation. Also adds a dev-only puppet (feature browser-smoke) that executes JSON commands from a control directory through the same _core functions, used to verify the surface without UI automation. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 23 +- src-tauri/src/browser/events.rs | 23 ++ src-tauri/src/browser/hooks.rs | 62 ++++ src-tauri/src/browser/mod.rs | 48 +++ src-tauri/src/browser/policy.rs | 72 ++++ src-tauri/src/browser/registry.rs | 119 ++++++ src-tauri/src/browser/smoke.rs | 317 ++++++++++++++++ src-tauri/src/browser/surface.rs | 133 +++++++ src-tauri/src/browser/surface_child.rs | 238 ++++++++++++ src-tauri/src/browser/surface_window.rs | 68 ++++ src-tauri/src/browser/types.rs | 157 ++++++++ src-tauri/src/commands/browser.rs | 464 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 28 ++ 15 files changed, 1754 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/browser/events.rs create mode 100644 src-tauri/src/browser/hooks.rs create mode 100644 src-tauri/src/browser/mod.rs create mode 100644 src-tauri/src/browser/policy.rs create mode 100644 src-tauri/src/browser/registry.rs create mode 100644 src-tauri/src/browser/smoke.rs create mode 100644 src-tauri/src/browser/surface.rs create mode 100644 src-tauri/src/browser/surface_child.rs create mode 100644 src-tauri/src/browser/surface_window.rs create mode 100644 src-tauri/src/browser/types.rs create mode 100644 src-tauri/src/commands/browser.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b29660fccf..10987999d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1103,6 +1103,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-runtime-wry", "temp-env", "tempfile", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46c2807ded..2d8062ecec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ name = "codeg_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] -default = ["tauri-runtime"] +default = ["tauri-runtime", "browser-child"] tauri-runtime = [ "dep:tauri", "dep:tauri-plugin-opener", @@ -36,6 +36,19 @@ tauri-runtime = [ # `db::test_helpers` module) for integration tests in `tests/*.rs`. Without # this feature the items are physically uncompiled in release builds. test-utils = [] +# Built-in browser. `browser-child` compiles the embedded surface: a wry child +# webview created directly through tauri-runtime-wry's re-exported `wry` +# (`WebViewBuilder::build_as_child`), which is exactly how tauri-runtime-wry +# builds its own child webviews. Going through wry instead of tauri's +# `Window::add_child` keeps the workspace window a plain `WebviewWindow` in +# tauri's eyes — with a tauri child attached, `is_webview_window()` turns +# false, `get_webview_window("main")` returns None and every command taking a +# `tauri::WebviewWindow` argument fails — and it needs no `unstable` feature. +# Only macOS and Windows compile the child surface; Linux uses owned windows +# (wry's child build is X11-only and cannot be positioned on Wayland). +browser-child = ["dep:tauri-runtime-wry"] +# Dev-only puppet driven by a JSON control file (see src/browser/smoke.rs). +browser-smoke = [] [[bin]] name = "codeg" @@ -57,6 +70,8 @@ tauri-build = { version = "2", features = [], optional = true } [dependencies] tauri = { version = "2.11", features = ["macos-private-api", "tray-icon"], optional = true } +# Only for its `wry` re-export (version-locked to the runtime tauri uses). +tauri-runtime-wry = { version = "2.11", optional = true, default-features = false } tauri-plugin-opener = { version = "2", optional = true } tauri-plugin-dialog = { version = "2", optional = true } async-trait = "0.1" @@ -153,6 +168,12 @@ tauri-plugin-single-instance = { version = "2", optional = true } # LaunchAgent plist on macOS, an XDG autostart .desktop entry on Linux. tauri-plugin-autostart = { version = "2", optional = true } +# Feature unification is per target: `devtools` (release-build web inspector +# for browser tabs, via tauri-runtime-wry → wry) only where the embedded +# browser surface exists. +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +tauri = { version = "2.11", features = ["devtools"], optional = true } + [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "0.6" diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs new file mode 100644 index 0000000000..d603cadd16 --- /dev/null +++ b/src-tauri/src/browser/events.rs @@ -0,0 +1,23 @@ +//! Fan-out of tab state to the frontend. One full `BrowserTabState` per +//! change on `browser://state`; the frontend filters by `tabId`. + +use tauri::AppHandle; + +use crate::web::event_bridge::{emit_event, EventEmitter}; + +use super::types::{BrowserClosedPayload, BrowserTabState, CLOSED_EVENT, STATE_EVENT}; + +pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { + emit_event(&EventEmitter::Tauri(app.clone()), STATE_EVENT, state); +} + +pub fn emit_closed(app: &AppHandle, tab_id: &str, owner_window: &str) { + emit_event( + &EventEmitter::Tauri(app.clone()), + CLOSED_EVENT, + BrowserClosedPayload { + tab_id: tab_id.to_string(), + owner_window: owner_window.to_string(), + }, + ); +} diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs new file mode 100644 index 0000000000..35eb514763 --- /dev/null +++ b/src-tauri/src/browser/hooks.rs @@ -0,0 +1,62 @@ +//! Callbacks the surfaces install on their webviews. They only touch the +//! registry and emit state; nothing here calls back into the surface, so the +//! webview thread never waits on itself. + +use tauri::{AppHandle, Manager, Url}; + +use super::events; +use super::registry::BrowserRegistry; + +pub fn origin_of(url: &Url) -> Option { + let origin = url.origin(); + if origin.is_tuple() { + Some(origin.ascii_serialization()) + } else { + None + } +} + +pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { + let Some(registry) = app.try_state::() else { + return; + }; + let state = registry.update_state(tab_id, |state| { + state.url = url.to_string(); + state.loading = started; + state.origin = origin_of(url); + if started { + state.error = None; + // The previous document's title must not label the new one; the + // toolbar falls back to the host until `title_changed` fires. + state.title.clear(); + } + }); + if let Some(state) = state { + events::emit_state(app, &state); + } +} + +pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { + let Some(registry) = app.try_state::() else { + return; + }; + let state = registry.update_state(tab_id, |state| state.title = title); + if let Some(state) = state { + events::emit_state(app, &state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn origin_is_none_for_opaque_urls() { + assert_eq!( + origin_of(&Url::parse("https://example.com:8443/a").unwrap()).as_deref(), + Some("https://example.com:8443") + ); + assert_eq!(origin_of(&Url::parse("about:blank").unwrap()), None); + assert_eq!(origin_of(&Url::parse("blob:null/x").unwrap()), None); + } +} diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs new file mode 100644 index 0000000000..eee0ea0142 --- /dev/null +++ b/src-tauri/src/browser/mod.rs @@ -0,0 +1,48 @@ +//! Built-in browser. +//! +//! A browser tab is a Rust-owned webview that renders an arbitrary web page +//! next to the chat: on macOS / Windows a wry child webview embedded in the +//! workspace window at the bounds of a placeholder element (see Cargo.toml's +//! `browser-child` note for why wry is driven directly rather than through +//! tauri's `add_child`), on Linux (and as +//! the fallback everywhere) an owned top-level window. The frontend only ever +//! talks to it through the `browser_*` commands and the `browser://*` events; +//! the page itself has no Tauri IPC at all — `browser-*` labels are absent from +//! every capability on purpose, so a page script cannot reach any command. +//! +//! Module map: +//! - `types` — wire types shared with `src/lib/browser/types.ts` +//! - `policy` — pure decisions (scheme allow-list, …) +//! - `registry` — tab id → surface + last known state +//! - `surface` — the enum over the concrete surfaces and their common ops +//! - `surface_child` / `surface_window` — the concrete builders +//! - `hooks` — webview callbacks (page load, title) → registry + events +//! - `events` — state fan-out to the frontend +//! - `smoke` — dev-only puppet driven by a JSON control file (feature +//! `browser-smoke`, never in a release build) + +pub mod events; +pub mod hooks; +pub mod policy; +pub mod registry; +pub mod surface; +#[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") +))] +pub mod surface_child; +pub mod surface_window; +pub mod types; + +#[cfg(feature = "browser-smoke")] +pub mod smoke; + +pub use registry::BrowserRegistry; + +/// Label prefix of every browser tab webview / window. Nothing under this +/// prefix may ever appear in `capabilities/*.json`. +pub const TAB_LABEL_PREFIX: &str = "browser-"; + +pub fn tab_label(tab_id: &str) -> String { + format!("{TAB_LABEL_PREFIX}{tab_id}") +} diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs new file mode 100644 index 0000000000..091e5a4120 --- /dev/null +++ b/src-tauri/src/browser/policy.rs @@ -0,0 +1,72 @@ +//! Pure policy decisions for browser tabs. Everything here is a function of +//! its arguments so the tables in the unit tests are the specification. + +use tauri::Url; + +/// Top-level navigation allow-list. Only real web pages may load in a tab: +/// `http(s)`, the `about:blank` bootstrap page every tab starts from, and +/// `blob:` URLs minted by an http(s) page (Turnstile and friends). Everything +/// else — `file:`, `tauri:`, `javascript:`, `data:` documents, custom schemes — +/// is refused: a tab must never be able to reach the app's own origin or the +/// local filesystem. +pub fn navigation_allowed(url: &Url) -> bool { + match url.scheme() { + "http" | "https" => true, + "about" => url.as_str() == "about:blank", + "blob" => { + let inner = url.path(); + inner.starts_with("http://") || inner.starts_with("https://") + } + _ => false, + } +} + +/// The initial URL handed to `browser_open_tab` must already be a web page; +/// `about:blank` is accepted so an empty tab can be opened explicitly. +pub fn open_url_allowed(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") || url.as_str() == "about:blank" +} + +#[cfg(test)] +mod tests { + use super::*; + + fn u(s: &str) -> Url { + Url::parse(s).unwrap() + } + + #[test] + fn navigation_allow_list() { + for ok in [ + "http://localhost:3000/", + "https://example.com/a?b#c", + "about:blank", + "blob:https://example.com/0c8f-4a", + "blob:http://localhost:3000/x", + ] { + assert!(navigation_allowed(&u(ok)), "{ok}"); + } + for bad in [ + "file:///etc/passwd", + "tauri://localhost/", + "javascript:alert(1)", + "data:text/html,x", + "about:config", + "blob:null/abc", + "blob:file:///x", + "codeg-doc://grant/index.html", + "ftp://example.com/", + "vscode://file/x", + ] { + assert!(!navigation_allowed(&u(bad)), "{bad}"); + } + } + + #[test] + fn open_url_is_stricter_than_navigation() { + assert!(open_url_allowed(&u("https://example.com"))); + assert!(open_url_allowed(&u("about:blank"))); + assert!(!open_url_allowed(&u("blob:https://example.com/x"))); + assert!(!open_url_allowed(&u("file:///x"))); + } +} diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs new file mode 100644 index 0000000000..4bcf4fd0c9 --- /dev/null +++ b/src-tauri/src/browser/registry.rs @@ -0,0 +1,119 @@ +//! `tab id → surface + last known state`, shared by the commands, the +//! webview hooks and the window-close cleanup. The mutex is only ever held +//! for map operations; every surface call happens on a clone taken out of it. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard}; + +use crate::app_error::AppCommandError; + +use super::surface::BrowserSurface; +use super::types::{Bounds, BrowserTabState}; + +pub struct BrowserTab { + pub state: BrowserTabState, + pub surface: BrowserSurface, + /// Last bounds the frontend asked for; re-applied when the surface is + /// shown again after being hidden. + pub last_bounds: Bounds, + pub visible: bool, +} + +#[derive(Default)] +pub struct BrowserRegistry { + tabs: Mutex>, +} + +impl BrowserRegistry { + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.tabs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn insert(&self, tab: BrowserTab) -> Result<(), AppCommandError> { + let mut tabs = self.lock(); + let id = tab.state.tab_id.clone(); + if tabs.contains_key(&id) { + return Err(AppCommandError::already_exists(format!( + "browser tab {id} is already open" + ))); + } + tabs.insert(id, tab); + Ok(()) + } + + pub fn contains(&self, tab_id: &str) -> bool { + self.lock().contains_key(tab_id) + } + + /// A clone of the surface handle, to be used with the lock released. + pub fn surface(&self, tab_id: &str) -> Option { + self.lock().get(tab_id).map(|t| t.surface.clone()) + } + + pub fn state(&self, tab_id: &str) -> Option { + self.lock().get(tab_id).map(|t| t.state.clone()) + } + + pub fn list(&self) -> Vec { + let mut states: Vec<_> = self.lock().values().map(|t| t.state.clone()).collect(); + states.sort_by(|a, b| a.tab_id.cmp(&b.tab_id)); + states + } + + pub fn list_for_owner(&self, owner_window: &str) -> Vec { + self.list() + .into_iter() + .filter(|s| s.owner_window == owner_window) + .collect() + } + + /// Mutate a tab under the lock and return whatever the closure produced, + /// or `None` when the tab is gone. Keep the closure free of surface calls. + pub fn update(&self, tab_id: &str, f: impl FnOnce(&mut BrowserTab) -> R) -> Option { + self.lock().get_mut(tab_id).map(f) + } + + /// Update and hand back the resulting state (the usual "mutate then emit" + /// shape). + pub fn update_state( + &self, + tab_id: &str, + f: impl FnOnce(&mut BrowserTabState), + ) -> Option { + self.update(tab_id, |tab| { + f(&mut tab.state); + tab.state.clone() + }) + } + + pub fn tab_id_for_label(&self, label: &str) -> Option { + self.lock() + .values() + .find(|t| t.surface.label() == label) + .map(|t| t.state.tab_id.clone()) + } + + pub fn remove(&self, tab_id: &str) -> Option { + self.lock().remove(tab_id) + } + + /// Detach every tab owned by a window (called when that window is + /// destroyed); the caller closes the returned surfaces. + pub fn remove_by_owner(&self, owner_window: &str) -> Vec { + let mut tabs = self.lock(); + let ids: Vec = tabs + .values() + .filter(|t| t.state.owner_window == owner_window) + .map(|t| t.state.tab_id.clone()) + .collect(); + ids.into_iter().filter_map(|id| tabs.remove(&id)).collect() + } + + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } +} diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs new file mode 100644 index 0000000000..79521485ef --- /dev/null +++ b/src-tauri/src/browser/smoke.rs @@ -0,0 +1,317 @@ +//! Dev-only puppet for the built-in browser (Cargo feature `browser-smoke`, +//! never part of a release build). +//! +//! The desktop app cannot be driven from outside without macOS Accessibility +//! rights, which the automation environment does not have, so P0 verification +//! drives the app from the inside instead: when `CODEG_BROWSER_SMOKE_DIR` is +//! set, a task polls `/cmd.json` for `{ "id": n, "op": "...", ... }`, +//! executes the operation through the same `_core` functions the commands +//! use, and writes `/result-.json`. Screenshots are taken from the +//! outside with `screencapture -l `. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; + +use crate::browser::registry::BrowserRegistry; +use crate::browser::types::{Bounds, SurfaceChoice}; +use crate::commands::browser as browser_commands; + +pub fn spawn_if_enabled(app: AppHandle) { + let Ok(dir) = std::env::var("CODEG_BROWSER_SMOKE_DIR") else { + return; + }; + let dir = PathBuf::from(dir); + tracing::warn!("[browser-smoke] enabled, watching {}", dir.display()); + tauri::async_runtime::spawn(async move { + let mut last_id: u64 = 0; + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let Ok(raw) = std::fs::read_to_string(dir.join("cmd.json")) else { + continue; + }; + let Ok(cmd) = serde_json::from_str::(&raw) else { + continue; + }; + let id = cmd.get("id").and_then(Value::as_u64).unwrap_or(0); + if id == 0 || id <= last_id { + continue; + } + last_id = id; + let started = Instant::now(); + let result = match execute(&app, &cmd).await { + Ok(value) => json!({ "id": id, "ok": true, "result": value }), + Err(error) => json!({ "id": id, "ok": false, "error": error }), + }; + let mut result = result; + result["ms"] = json!(started.elapsed().as_millis() as u64); + write_result(&dir, id, &result); + } + }); +} + +fn write_result(dir: &Path, id: u64, result: &Value) { + let tmp = dir.join(format!("result-{id}.json.tmp")); + let dest = dir.join(format!("result-{id}.json")); + let body = serde_json::to_string_pretty(result).unwrap_or_default(); + if std::fs::write(&tmp, body).is_ok() { + let _ = std::fs::rename(&tmp, &dest); + } +} + +fn str_arg(cmd: &Value, key: &str) -> Result { + cmd.get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("missing string argument {key:?}")) +} + +fn bounds_arg(cmd: &Value) -> Result { + let b = cmd.get("bounds").ok_or("missing bounds")?; + serde_json::from_value(b.clone()).map_err(|e| format!("bad bounds: {e}")) +} + +fn err_string(err: impl std::fmt::Display) -> String { + err.to_string() +} + +async fn execute(app: &AppHandle, cmd: &Value) -> Result { + let op = str_arg(cmd, "op")?; + let main_window = || { + app.get_webview_window("main") + .ok_or_else(|| "no main window".to_string()) + }; + let owner = || -> Result { + match cmd.get("owner").and_then(Value::as_str) { + Some(label) => app + .get_webview_window(label) + .ok_or_else(|| format!("no window {label:?}")), + None => main_window(), + } + }; + let registry = app.state::(); + + match op.as_str() { + "ping" => Ok(json!("pong")), + "sleep" => { + let ms = cmd.get("ms").and_then(Value::as_u64).unwrap_or(500); + tokio::time::sleep(Duration::from_millis(ms)).await; + Ok(Value::Null) + } + "list_windows" => { + let mut out: Vec = app + .webview_windows() + .into_iter() + .map(|(label, w)| { + json!({ + "label": label, + "visible": w.is_visible().ok(), + "focused": w.is_focused().ok(), + "minimized": w.is_minimized().ok(), + "position": w.outer_position().ok().map(|p| [p.x, p.y]), + "size": w.inner_size().ok().map(|s| [s.width, s.height]), + "scale": w.scale_factor().ok(), + "url": w.url().ok().map(|u| u.to_string()), + }) + }) + .collect(); + out.sort_by(|a, b| a["label"].as_str().cmp(&b["label"].as_str())); + Ok(Value::Array(out)) + } + "webviews" => { + let mut windows: Vec = app.webview_windows().keys().cloned().collect(); + windows.sort(); + let tabs: Vec = registry + .list() + .into_iter() + .map(|s| crate::browser::tab_label(&s.tab_id)) + .collect(); + Ok(json!({ "windows": windows, "tabs": tabs })) + } + "open_settings" => { + let main = main_window()?; + crate::commands::windows::open_settings_window( + app.clone(), + main, + app.state(), + None, + None, + None, + None, + app.state(), + ) + .await + .map_err(err_string)?; + Ok(Value::Null) + } + "open_import_sessions" => { + crate::commands::windows::open_import_sessions_window( + app.clone(), + app.state(), + None, + None, + None, + ) + .await + .map_err(err_string)?; + Ok(Value::Null) + } + "open_project_boot" => { + crate::commands::windows::open_project_boot_window( + app.clone(), + app.state(), + None, + None, + None, + ) + .await + .map_err(err_string)?; + Ok(Value::Null) + } + "open_commit" => { + let folder_id = cmd + .get("folder_id") + .and_then(Value::as_i64) + .ok_or("missing folder_id")? as i32; + let main = main_window()?; + crate::commands::windows::open_commit_window( + app.clone(), + main, + app.state(), + app.state(), + folder_id, + None, + None, + ) + .await + .map_err(err_string)?; + Ok(Value::Null) + } + "open_pet" => { + crate::commands::windows::open_pet_window(app.clone(), app.state()) + .await + .map_err(err_string)?; + Ok(Value::Null) + } + "close_window" | "focus_window" | "minimize_window" | "unminimize_window" + | "hide_window" | "show_window" => { + let label = str_arg(cmd, "label")?; + let w = app + .get_webview_window(&label) + .ok_or_else(|| format!("no window {label:?}"))?; + let r = match op.as_str() { + "close_window" => w.close(), + "focus_window" => w.set_focus(), + "minimize_window" => w.minimize(), + "unminimize_window" => w.unminimize(), + "hide_window" => w.hide(), + _ => w.show(), + }; + r.map_err(err_string)?; + Ok(Value::Null) + } + "browser_open" => { + let owner = owner()?; + let surface = match cmd.get("surface").and_then(Value::as_str) { + Some("child") => SurfaceChoice::Child, + Some("window") => SurfaceChoice::Window, + _ => SurfaceChoice::Auto, + }; + let state = browser_commands::open_tab_core( + app, + &owner, + ®istry, + browser_commands::OpenTabParams { + tab_id: str_arg(cmd, "tab_id")?, + url: str_arg(cmd, "url")?, + bounds: bounds_arg(cmd)?, + background: cmd + .get("background") + .and_then(Value::as_bool) + .unwrap_or(false), + surface, + }, + ) + .map_err(err_string)?; + Ok(json!(state)) + } + "browser_set_bounds" => { + browser_commands::set_bounds_core(®istry, &str_arg(cmd, "tab_id")?, bounds_arg(cmd)?) + .map_err(err_string)?; + Ok(Value::Null) + } + "browser_set_visible" => { + let owner = owner()?; + browser_commands::set_visible_core( + &owner, + ®istry, + &str_arg(cmd, "tab_id")?, + cmd.get("visible").and_then(Value::as_bool).unwrap_or(true), + cmd.get("handoff_focus") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .map_err(err_string)?; + Ok(Value::Null) + } + "browser_navigate" => { + let state = browser_commands::navigate_core( + app, + ®istry, + &str_arg(cmd, "tab_id")?, + &str_arg(cmd, "url")?, + ) + .map_err(err_string)?; + Ok(json!(state)) + } + "browser_reload" => { + browser_commands::reload_core(®istry, &str_arg(cmd, "tab_id")?).map_err(err_string)?; + Ok(Value::Null) + } + "browser_close" => { + browser_commands::close_core(app, ®istry, &str_arg(cmd, "tab_id")?) + .map_err(err_string)?; + Ok(Value::Null) + } + "browser_state" => { + let state = browser_commands::state_core(®istry, &str_arg(cmd, "tab_id")?) + .map_err(err_string)?; + Ok(json!(state)) + } + "browser_list" => Ok(json!(registry.list())), + "browser_url" => { + let surface = registry + .surface(&str_arg(cmd, "tab_id")?) + .ok_or("no such tab")?; + Ok(json!(surface.url().map_err(err_string)?.to_string())) + } + "browser_eval" => { + let surface = registry + .surface(&str_arg(cmd, "tab_id")?) + .ok_or("no such tab")?; + let js = str_arg(cmd, "js")?; + let (tx, rx) = std::sync::mpsc::channel::(); + surface + .eval_with_callback(&js, move |value| { + let _ = tx.send(value); + }) + .map_err(err_string)?; + let timeout = Duration::from_millis(cmd.get("timeout_ms").and_then(Value::as_u64).unwrap_or(8000)); + let value = tokio::task::spawn_blocking(move || rx.recv_timeout(timeout)) + .await + .map_err(err_string)? + .map_err(|_| "eval timed out".to_string())?; + Ok(serde_json::from_str(&value).unwrap_or(Value::String(value))) + } + "browser_focus" => { + let surface = registry + .surface(&str_arg(cmd, "tab_id")?) + .ok_or("no such tab")?; + surface.set_focus().map_err(err_string)?; + Ok(Value::Null) + } + other => Err(format!("unknown op {other:?}")), + } +} diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs new file mode 100644 index 0000000000..89649c6207 --- /dev/null +++ b/src-tauri/src/browser/surface.rs @@ -0,0 +1,133 @@ +//! The concrete surfaces behind a tab and the operations the command layer +//! needs from all of them. Both handle types are cheap `Send + Clone` values +//! that dispatch to the main thread internally, so callers clone a surface OUT +//! of the registry and operate on it with no lock held — holding the registry +//! mutex across a main-thread round trip would deadlock the moment the main +//! thread wants the registry too. + +use tauri::Url; + +use super::types::{Bounds, SurfaceKind}; + +#[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") +))] +use super::surface_child::ChildHandle; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct SurfaceError(pub String); + +impl From for SurfaceError { + fn from(err: tauri::Error) -> Self { + SurfaceError(err.to_string()) + } +} + +#[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") +))] +impl From for SurfaceError { + fn from(err: super::surface_child::ChildError) -> Self { + SurfaceError(err.to_string()) + } +} + +#[derive(Clone)] +pub enum BrowserSurface { + #[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") + ))] + Child(ChildHandle), + Window(tauri::WebviewWindow), +} + +// The child arm is compiled out on Linux; the macro keeps every method to one +// match instead of three cfg-laden copies. +macro_rules! per_surface { + ($self:ident, child: |$c:ident| $child:expr, window: |$w:ident| $window:expr) => { + match $self { + #[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") + ))] + BrowserSurface::Child($c) => $child, + BrowserSurface::Window($w) => $window, + } + }; +} + +impl BrowserSurface { + pub fn kind(&self) -> SurfaceKind { + per_surface!(self, child: |_c| SurfaceKind::Child, window: |_w| SurfaceKind::Window) + } + + pub fn is_embedded(&self) -> bool { + self.kind() != SurfaceKind::Window + } + + pub fn label(&self) -> &str { + per_surface!(self, child: |c| c.label(), window: |w| w.label()) + } + + pub fn navigate(&self, url: Url) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.load_url(url.as_str())?), + window: |w| Ok(w.navigate(url)?)) + } + + pub fn reload(&self) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.reload()?), window: |w| Ok(w.reload()?)) + } + + pub fn url(&self) -> Result { + per_surface!(self, + child: |c| Url::parse(&c.url()?).map_err(|e| SurfaceError(e.to_string())), + window: |w| Ok(w.url()?)) + } + + pub fn eval(&self, js: &str) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.evaluate_script(js)?), window: |w| Ok(w.eval(js)?)) + } + + pub fn eval_with_callback( + &self, + js: &str, + callback: impl Fn(String) + Send + 'static, + ) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.evaluate_script_with_callback(js, callback)?), + window: |w| Ok(w.eval_with_callback(js, callback)?)) + } + + pub fn hide(&self) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.set_visible(false)?), window: |w| Ok(w.hide()?)) + } + + pub fn show(&self) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.set_visible(true)?), window: |w| Ok(w.show()?)) + } + + pub fn set_focus(&self) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.focus()?), window: |w| Ok(w.set_focus()?)) + } + + pub fn close(&self) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.close()?), window: |w| Ok(w.close()?)) + } + + /// Only meaningful for embedded surfaces; an owned window keeps whatever + /// size and position the user gave it. + pub fn set_bounds(&self, bounds: Bounds) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.set_bounds(bounds)?), + window: |_w| { let _ = bounds; Ok(()) }) + } + + pub fn set_zoom(&self, factor: f64) -> Result<(), SurfaceError> { + per_surface!(self, child: |c| Ok(c.zoom(factor)?), window: |w| Ok(w.set_zoom(factor)?)) + } +} diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs new file mode 100644 index 0000000000..9cdfdaded0 --- /dev/null +++ b/src-tauri/src/browser/surface_child.rs @@ -0,0 +1,238 @@ +//! Embedded surface for macOS / Windows: a wry child webview built straight +//! through tauri-runtime-wry's re-exported `wry` (`build_as_child` on the +//! owner window — the same call tauri-runtime-wry makes for its own child +//! webviews). tauri never learns about the view, so the workspace window stays +//! an ordinary `WebviewWindow` (see the `browser-child` note in Cargo.toml). +//! +//! wry's `WebView` is not `Send`; every instance lives in a thread-local on +//! the main thread and is only ever touched there. `ChildHandle` is the +//! `Send + Clone` stand-in the registry and the commands hold: each operation +//! hops to the main thread and waits for the answer, or runs inline when the +//! caller already is the main thread (wry handlers, window-event hooks). +//! Never call into a handle while holding the registry mutex — the main +//! thread may need that mutex to finish the very operation being waited on. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::mpsc; +use std::sync::OnceLock; +use std::thread::ThreadId; + +use tauri::{AppHandle, Url, WebviewWindow}; +use tauri_runtime_wry::wry::{self, dpi, PageLoadEvent, Rect, WebViewBuilder}; + +use super::hooks; +use super::policy; +use super::types::Bounds; + +thread_local! { + static SURFACES: RefCell> = RefCell::new(HashMap::new()); +} + +static MAIN_THREAD: OnceLock = OnceLock::new(); + +/// Must be called once from the main thread (tauri's `setup` hook) before any +/// surface is created; lets `ChildHandle` run inline instead of deadlocking +/// when it is used from a wry callback. +pub fn init_main_thread() { + let _ = MAIN_THREAD.set(std::thread::current().id()); +} + +fn on_main_thread() -> bool { + MAIN_THREAD.get() == Some(&std::thread::current().id()) +} + +#[derive(Debug, thiserror::Error)] +pub enum ChildError { + #[error("browser surface {0} is gone")] + Gone(String), + #[error("main-thread dispatch failed: {0}")] + Dispatch(String), + #[error("{0}")] + Op(String), +} + +fn run_on_main( + app: &AppHandle, + f: impl FnOnce() -> R + Send + 'static, +) -> Result { + if on_main_thread() { + return Ok(f()); + } + let (tx, rx) = mpsc::channel(); + app.run_on_main_thread(move || { + let _ = tx.send(f()); + }) + .map_err(|e| ChildError::Dispatch(e.to_string()))?; + rx.recv().map_err(|e| ChildError::Dispatch(e.to_string())) +} + +fn rect(bounds: Bounds) -> Rect { + Rect { + position: dpi::Position::Logical(dpi::LogicalPosition::new(bounds.x, bounds.y)), + size: dpi::Size::Logical(dpi::LogicalSize::new(bounds.width, bounds.height)), + } +} + +#[derive(Clone, Debug)] +pub struct ChildHandle { + tab_id: String, + label: String, + app: AppHandle, +} + +impl ChildHandle { + pub fn label(&self) -> &str { + &self.label + } + + /// Run `f` against the live webview on the main thread. + fn with( + &self, + f: impl FnOnce(&wry::WebView) -> R + Send + 'static, + ) -> Result { + let id = self.tab_id.clone(); + run_on_main(&self.app, move || { + SURFACES.with(|s| s.borrow().get(&id).map(f)) + })? + .ok_or_else(|| ChildError::Gone(self.tab_id.clone())) + } + + fn op( + &self, + f: impl FnOnce(&wry::WebView) -> wry::Result<()> + Send + 'static, + ) -> Result<(), ChildError> { + self.with(move |wv| f(wv).map_err(|e| e.to_string()))? + .map_err(ChildError::Op) + } + + pub fn load_url(&self, url: &str) -> Result<(), ChildError> { + let url = url.to_string(); + self.op(move |wv| wv.load_url(&url)) + } + + pub fn reload(&self) -> Result<(), ChildError> { + self.op(|wv| wv.reload()) + } + + pub fn url(&self) -> Result { + self.with(|wv| wv.url().map_err(|e| e.to_string()))? + .map_err(ChildError::Op) + } + + pub fn evaluate_script(&self, js: &str) -> Result<(), ChildError> { + let js = js.to_string(); + self.op(move |wv| wv.evaluate_script(&js)) + } + + pub fn evaluate_script_with_callback( + &self, + js: &str, + callback: impl Fn(String) + Send + 'static, + ) -> Result<(), ChildError> { + let js = js.to_string(); + self.op(move |wv| wv.evaluate_script_with_callback(&js, callback)) + } + + pub fn set_bounds(&self, bounds: Bounds) -> Result<(), ChildError> { + self.op(move |wv| wv.set_bounds(rect(bounds))) + } + + pub fn set_visible(&self, visible: bool) -> Result<(), ChildError> { + self.op(move |wv| wv.set_visible(visible)) + } + + pub fn focus(&self) -> Result<(), ChildError> { + self.op(|wv| wv.focus()) + } + + pub fn zoom(&self, factor: f64) -> Result<(), ChildError> { + self.op(move |wv| wv.zoom(factor)) + } + + pub fn open_devtools(&self) -> Result<(), ChildError> { + self.with(|wv| wv.open_devtools()) + } + + pub fn clear_all_browsing_data(&self) -> Result<(), ChildError> { + self.op(|wv| wv.clear_all_browsing_data()) + } + + /// Detach and drop the webview (on the main thread; wry removes the + /// native view from the window when the `WebView` drops). + pub fn close(&self) -> Result<(), ChildError> { + let id = self.tab_id.clone(); + let removed = run_on_main(&self.app, move || { + SURFACES.with(|s| s.borrow_mut().remove(&id)).is_some() + })?; + if removed { + Ok(()) + } else { + Err(ChildError::Gone(self.tab_id.clone())) + } + } +} + +/// Build the child webview on `about:blank` at `bounds`. The caller navigates +/// afterwards, once the page ↔ host channel is installed. +pub fn create( + app: &AppHandle, + owner: &WebviewWindow, + tab_id: &str, + label: &str, + bounds: Bounds, + background: bool, +) -> Result { + let handle = ChildHandle { + tab_id: tab_id.to_string(), + label: label.to_string(), + app: app.clone(), + }; + let owner = owner.clone(); + let app_for_hooks = app.clone(); + let id = tab_id.to_string(); + let label = label.to_string(); + run_on_main(app, move || -> Result<(), String> { + let nav_id = id.clone(); + let builder = WebViewBuilder::new() + .with_id(&label) + .with_url("about:blank") + .with_bounds(rect(bounds)) + .with_visible(!background) + .with_focused(false) + .with_devtools(true) + .with_hotkeys_zoom(true) + .with_navigation_handler(move |url| { + let allowed = Url::parse(&url) + .map(|u| policy::navigation_allowed(&u)) + .unwrap_or(false); + if !allowed { + tracing::info!("[browser] tab {nav_id} blocked navigation to {url}"); + } + allowed + }) + .with_on_page_load_handler({ + let app = app_for_hooks.clone(); + let id = id.clone(); + move |event, url| { + if let Ok(url) = Url::parse(&url) { + hooks::page_load(&app, &id, &url, matches!(event, PageLoadEvent::Started)); + } + } + }) + .with_document_title_changed_handler({ + let app = app_for_hooks.clone(); + let id = id.clone(); + move |title| hooks::title_changed(&app, &id, title) + }) + // Downloads are refused until the download UI exists (P2). + .with_download_started_handler(|_url, _destination| false); + let webview = builder + .build_as_child(&owner) + .map_err(|e| e.to_string())?; + SURFACES.with(|s| s.borrow_mut().insert(id, webview)); + Ok(()) + })? + .map_err(ChildError::Op)?; + Ok(handle) +} diff --git a/src-tauri/src/browser/surface_window.rs b/src-tauri/src/browser/surface_window.rs new file mode 100644 index 0000000000..109750578a --- /dev/null +++ b/src-tauri/src/browser/surface_window.rs @@ -0,0 +1,68 @@ +//! Owned-window surface: a top-level `WebviewWindow` attached to the owner +//! (`parent`). The only surface on Linux, the fallback everywhere else, and +//! the shape popups take when they cannot be adopted as tabs. + +use tauri::webview::PageLoadEvent; +use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent}; + +use super::events; +use super::hooks; +use super::policy; +use super::registry::BrowserRegistry; + +pub fn create( + app: &AppHandle, + owner: &WebviewWindow, + tab_id: &str, + label: &str, + title: &str, + background: bool, +) -> tauri::Result { + let blank = Url::parse("about:blank").expect("static url"); + let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::External(blank)) + .title(title) + .inner_size(1100.0, 760.0) + .min_inner_size(480.0, 320.0) + .focused(!background) + .on_navigation(policy::navigation_allowed) + .on_page_load({ + let app = app.clone(); + let tab_id = tab_id.to_string(); + move |_window, payload| { + hooks::page_load( + &app, + &tab_id, + payload.url(), + matches!(payload.event(), PageLoadEvent::Started), + ) + } + }) + .on_document_title_changed({ + let app = app.clone(); + let tab_id = tab_id.to_string(); + move |_window, title| hooks::title_changed(&app, &tab_id, title) + }) + .on_download(|_webview, _event| false) + .disable_drag_drop_handler() + .zoom_hotkeys_enabled(true) + .browser_extensions_enabled(false); + let builder = builder.parent(owner)?; + let window = builder.build()?; + + // The user can close an owned window directly; drop the tab and tell the + // frontend so the tab strip follows. + { + let app = app.clone(); + let tab_id = tab_id.to_string(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Destroyed) { + if let Some(registry) = app.try_state::() { + if let Some(tab) = registry.remove(&tab_id) { + events::emit_closed(&app, &tab_id, &tab.state.owner_window); + } + } + } + }); + } + Ok(window) +} diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs new file mode 100644 index 0000000000..d40809c5f0 --- /dev/null +++ b/src-tauri/src/browser/types.rs @@ -0,0 +1,157 @@ +//! Wire types for the built-in browser. `src/lib/browser/types.ts` mirrors +//! these one to one; both sides use camelCase field names and kebab-case enum +//! values. + +use serde::{Deserialize, Serialize}; + +/// Which concrete surface renders a tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SurfaceKind { + /// wry child webview embedded in the owner window (macOS / Windows). + Child, + /// Owned top-level window (`WebviewWindowBuilder::parent`). + Window, +} + +/// How the page ↔ host channel was installed for a tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChannelKind { + /// Isolated-world helper + native message handler. + Native, + /// Installation failed; only navigation interception is available. + Degraded, + /// Platform too old for isolated worlds (macOS < 11); page-world helper. + Legacy, +} + +/// Placement of the surface inside the owner window, in logical pixels — the +/// same unit `getBoundingClientRect()` reports (the workspace content area is +/// the whole window and the app's zoom changes the root font size, not the +/// webview scale). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Bounds { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrowserErrorKind { + Dns, + Tls, + Blocked, + Failed, + PopupDenied, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserErrorInfo { + pub kind: BrowserErrorKind, + pub message: String, + pub url: Option, +} + +/// Everything the toolbar / status layer renders for one tab. Emitted in full +/// on every change (`browser://state`); the frontend keeps it in a store keyed +/// by `tab_id` rather than inside the workspace tab record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserTabState { + pub tab_id: String, + /// Label of the window the tab belongs to (`main`, `remote-workspace-*`). + pub owner_window: String, + pub surface: SurfaceKind, + pub channel: ChannelKind, + /// Last committed URL. + pub url: String, + /// URL the last navigation was asked for (differs from `url` while loading + /// or after a redirect). + pub requested_url: String, + pub title: String, + pub favicon: Option, + pub loading: bool, + pub can_go_back: bool, + pub can_go_forward: bool, + pub origin: Option, + pub zoom: f64, + pub error: Option, + /// Set when the tab's traffic egresses through a remote workspace host. + pub remote_host: Option, +} + +/// Answer to `browser_capabilities`: what this build on this machine can do. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserCapabilities { + pub available: bool, + pub surface: Option, + pub platform: String, + pub channel: ChannelKind, + /// Human-readable reasons behind a degraded answer (for diagnostics UI). + pub reasons: Vec, +} + +/// Caller's surface preference for `browser_open_tab`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum SurfaceChoice { + #[default] + Auto, + Child, + Window, +} + +pub const STATE_EVENT: &str = "browser://state"; +pub const CLOSED_EVENT: &str = "browser://closed"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserClosedPayload { + pub tab_id: String, + pub owner_window: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_names_are_camel_and_kebab() { + let state = BrowserTabState { + tab_id: "t1".into(), + owner_window: "main".into(), + surface: SurfaceKind::Child, + channel: ChannelKind::Native, + url: "about:blank".into(), + requested_url: "https://example.com/".into(), + title: String::new(), + favicon: None, + loading: true, + can_go_back: false, + can_go_forward: false, + origin: None, + zoom: 1.0, + error: None, + remote_host: None, + }; + let json = serde_json::to_value(&state).unwrap(); + assert_eq!(json["tabId"], "t1"); + assert_eq!(json["ownerWindow"], "main"); + assert_eq!(json["surface"], "child"); + assert_eq!(json["channel"], "native"); + assert_eq!(json["requestedUrl"], "https://example.com/"); + assert_eq!(json["canGoBack"], false); + + let bounds: Bounds = + serde_json::from_str(r#"{"x":1,"y":2.5,"width":300,"height":200}"#).unwrap(); + assert_eq!(bounds.y, 2.5); + let choice: SurfaceChoice = serde_json::from_str(r#""window""#).unwrap(); + assert_eq!(choice, SurfaceChoice::Window); + } +} diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs new file mode 100644 index 0000000000..daf2fe5c37 --- /dev/null +++ b/src-tauri/src/commands/browser.rs @@ -0,0 +1,464 @@ +//! `browser_*` commands: thin parameter validation over the registry. The +//! `_core` functions are the real implementation and are also driven by the +//! dev-only smoke puppet, so every code path the frontend uses is the one the +//! P0/P1 checks exercised. + +use tauri::{AppHandle, Manager, State, WebviewWindow}; +use tauri::Url; + +use crate::app_error::AppCommandError; +use crate::browser::registry::{BrowserRegistry, BrowserTab}; +use crate::browser::surface::BrowserSurface; +use crate::browser::types::{ + Bounds, BrowserCapabilities, BrowserTabState, ChannelKind, SurfaceChoice, SurfaceKind, +}; +use crate::browser::{events, policy, tab_label}; + +#[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") +))] +const CHILD_SURFACE_COMPILED: bool = true; +#[cfg(not(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") +)))] +const CHILD_SURFACE_COMPILED: bool = false; + +fn platform_name() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "other" + } +} + +/// What this build can do on this machine. `channel` stays `degraded` until +/// the isolated-world channel installer lands; the frontend keys off +/// `available` and `surface`. +pub fn capabilities() -> BrowserCapabilities { + let mut reasons = Vec::new(); + let surface = if CHILD_SURFACE_COMPILED { + SurfaceKind::Child + } else { + reasons.push(if cfg!(target_os = "linux") { + "linux: child webviews cannot be positioned; using owned windows".to_string() + } else { + "child surface not compiled; using owned windows".to_string() + }); + SurfaceKind::Window + }; + reasons.push("page channel not installed yet".to_string()); + BrowserCapabilities { + available: true, + surface: Some(surface), + platform: platform_name().to_string(), + channel: ChannelKind::Degraded, + reasons, + } +} + +fn pick_surface(choice: SurfaceChoice) -> SurfaceKind { + if CHILD_SURFACE_COMPILED && choice != SurfaceChoice::Window { + SurfaceKind::Child + } else { + SurfaceKind::Window + } +} + +/// Tab ids become webview labels, and a label is also what the popup and +/// capability checks key on, so keep them to a safe alphabet. +fn validate_tab_id(tab_id: &str) -> Result<(), AppCommandError> { + let ok = !tab_id.is_empty() + && tab_id.len() <= 64 + && tab_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if ok { + Ok(()) + } else { + Err(AppCommandError::invalid_input(format!( + "invalid browser tab id {tab_id:?}" + ))) + } +} + +fn parse_web_url(raw: &str) -> Result { + let url = Url::parse(raw.trim()) + .map_err(|e| AppCommandError::invalid_input(format!("invalid url {raw:?}: {e}")))?; + if !policy::open_url_allowed(&url) { + return Err(AppCommandError::invalid_input(format!( + "url scheme not allowed in a browser tab: {raw:?}" + ))); + } + Ok(url) +} + +/// `host[:port]` — an owned window's title never shows the path or query, so a +/// one-time token in an OAuth URL cannot leak through the window list. +pub fn origin_title(url: &Url) -> String { + match (url.host_str(), url.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + (None, _) => url.to_string(), + } +} + +fn window_err(what: &str, err: impl std::fmt::Display) -> AppCommandError { + AppCommandError::window(what.to_string(), err.to_string()) +} + +pub struct OpenTabParams { + pub tab_id: String, + pub url: String, + pub bounds: Bounds, + pub background: bool, + pub surface: SurfaceChoice, +} + +pub fn open_tab_core( + app: &AppHandle, + owner: &WebviewWindow, + registry: &BrowserRegistry, + params: OpenTabParams, +) -> Result { + validate_tab_id(¶ms.tab_id)?; + if registry.contains(¶ms.tab_id) { + return Err(AppCommandError::already_exists(format!( + "browser tab {} is already open", + params.tab_id + ))); + } + let url = parse_web_url(¶ms.url)?; + let label = tab_label(¶ms.tab_id); + + let surface = match pick_surface(params.surface) { + #[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") + ))] + SurfaceKind::Child => BrowserSurface::Child( + crate::browser::surface_child::create( + app, + owner, + ¶ms.tab_id, + &label, + params.bounds, + params.background, + ) + .map_err(|e| window_err("Failed to create browser webview", e))?, + ), + _ => BrowserSurface::Window( + crate::browser::surface_window::create( + app, + owner, + ¶ms.tab_id, + &label, + &origin_title(&url), + params.background, + ) + .map_err(|e| window_err("Failed to create browser window", e))?, + ), + }; + + let state = BrowserTabState { + tab_id: params.tab_id.clone(), + owner_window: owner.label().to_string(), + surface: surface.kind(), + channel: ChannelKind::Degraded, + url: "about:blank".to_string(), + requested_url: url.to_string(), + title: String::new(), + favicon: None, + loading: true, + can_go_back: false, + can_go_forward: false, + origin: None, + zoom: 1.0, + error: None, + remote_host: None, + }; + if let Err(err) = registry.insert(BrowserTab { + state: state.clone(), + surface: surface.clone(), + last_bounds: params.bounds, + visible: !params.background, + }) { + let _ = surface.close(); + return Err(err); + } + if params.background && surface.is_embedded() { + let _ = surface.hide(); + } + if let Err(err) = surface.navigate(url) { + registry.remove(¶ms.tab_id); + let _ = surface.close(); + return Err(window_err("Failed to navigate browser tab", err)); + } + events::emit_state(app, &state); + Ok(state) +} + +fn surface_of(registry: &BrowserRegistry, tab_id: &str) -> Result { + registry + .surface(tab_id) + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found"))) +} + +pub fn close_core(app: &AppHandle, registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { + if let Some(tab) = registry.remove(tab_id) { + let _ = tab.surface.close(); + events::emit_closed(app, tab_id, &tab.state.owner_window); + } + Ok(()) +} + +/// Called from the window-event hook when a window is destroyed: its tabs go +/// with it. Errors are ignored — a child webview of a destroyed window is +/// already gone. +pub fn close_all_for_owner(app: &AppHandle, owner_window: &str) { + if let Some(registry) = app.try_state::() { + for tab in registry.remove_by_owner(owner_window) { + let _ = tab.surface.close(); + } + } +} + +pub fn set_bounds_core( + registry: &BrowserRegistry, + tab_id: &str, + bounds: Bounds, +) -> Result<(), AppCommandError> { + let surface = surface_of(registry, tab_id)?; + let visible = registry + .update(tab_id, |tab| { + tab.last_bounds = bounds; + tab.visible + }) + .unwrap_or(false); + // A hidden surface picks the bounds up again when it is shown; moving it + // while hidden is wasted main-thread work on every split-pane drag. + if visible && surface.is_embedded() { + surface + .set_bounds(bounds) + .map_err(|e| window_err("Failed to move browser webview", e))?; + } + Ok(()) +} + +pub fn set_visible_core( + owner: &WebviewWindow, + registry: &BrowserRegistry, + tab_id: &str, + visible: bool, + handoff_focus: bool, +) -> Result<(), AppCommandError> { + let surface = surface_of(registry, tab_id)?; + let bounds = registry + .update(tab_id, |tab| { + tab.visible = visible; + tab.last_bounds + }) + .unwrap_or_default(); + if visible { + if surface.is_embedded() { + surface + .set_bounds(bounds) + .map_err(|e| window_err("Failed to move browser webview", e))?; + } + surface + .show() + .map_err(|e| window_err("Failed to show browser surface", e))?; + } else { + // Keyboard focus must not stay inside a hidden native view: the + // overlay that caused the hide would never receive Esc / Tab. + if handoff_focus { + let _ = owner.set_focus(); + } + surface + .hide() + .map_err(|e| window_err("Failed to hide browser surface", e))?; + } + Ok(()) +} + +pub fn navigate_core( + app: &AppHandle, + registry: &BrowserRegistry, + tab_id: &str, + raw_url: &str, +) -> Result { + let url = parse_web_url(raw_url)?; + let surface = surface_of(registry, tab_id)?; + let state = registry + .update_state(tab_id, |state| { + state.requested_url = url.to_string(); + state.loading = true; + state.error = None; + }) + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; + surface + .navigate(url) + .map_err(|e| window_err("Failed to navigate browser tab", e))?; + events::emit_state(app, &state); + Ok(state) +} + +pub fn reload_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { + surface_of(registry, tab_id)? + .reload() + .map_err(|e| window_err("Failed to reload browser tab", e)) +} + +pub fn state_core(registry: &BrowserRegistry, tab_id: &str) -> Result { + registry + .state(tab_id) + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found"))) +} + +#[tauri::command] +pub async fn browser_capabilities() -> Result { + Ok(capabilities()) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn browser_open_tab( + app: AppHandle, + window: WebviewWindow, + registry: State<'_, BrowserRegistry>, + tab_id: String, + url: String, + bounds: Bounds, + background: Option, + surface: Option, + folder_id: Option, +) -> Result { + // Folder scoping is a frontend concern (tab strip grouping); the backend + // only needs the owner window. + let _ = folder_id; + open_tab_core( + &app, + &window, + ®istry, + OpenTabParams { + tab_id, + url, + bounds, + background: background.unwrap_or(false), + surface: surface.unwrap_or_default(), + }, + ) +} + +#[tauri::command] +pub async fn browser_close( + app: AppHandle, + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result<(), AppCommandError> { + close_core(&app, ®istry, &tab_id) +} + +#[tauri::command] +pub async fn browser_set_bounds( + registry: State<'_, BrowserRegistry>, + tab_id: String, + bounds: Bounds, +) -> Result<(), AppCommandError> { + set_bounds_core(®istry, &tab_id, bounds) +} + +#[tauri::command] +pub async fn browser_set_visible( + window: WebviewWindow, + registry: State<'_, BrowserRegistry>, + tab_id: String, + visible: bool, + handoff_focus: Option, +) -> Result<(), AppCommandError> { + set_visible_core( + &window, + ®istry, + &tab_id, + visible, + handoff_focus.unwrap_or(false), + ) +} + +#[tauri::command] +pub async fn browser_navigate( + app: AppHandle, + registry: State<'_, BrowserRegistry>, + tab_id: String, + url: String, +) -> Result { + navigate_core(&app, ®istry, &tab_id, &url) +} + +#[tauri::command] +pub async fn browser_reload( + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result<(), AppCommandError> { + reload_core(®istry, &tab_id) +} + +#[tauri::command] +pub async fn browser_get_state( + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result { + state_core(®istry, &tab_id) +} + +#[tauri::command] +pub async fn browser_list_tabs( + window: WebviewWindow, + registry: State<'_, BrowserRegistry>, +) -> Result, AppCommandError> { + Ok(registry.list_for_owner(window.label())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tab_ids_are_label_safe() { + assert!(validate_tab_id("b7e2c1d0-1a2b").is_ok()); + assert!(validate_tab_id("tab_1").is_ok()); + for bad in ["", "a b", "a/b", "a:b", "é", &"x".repeat(65)] { + assert!(validate_tab_id(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn origin_title_hides_path_and_query() { + let url = Url::parse("https://accounts.example.com/o/oauth2?state=SECRET").unwrap(); + assert_eq!(origin_title(&url), "accounts.example.com"); + let url = Url::parse("http://localhost:3000/app#x").unwrap(); + assert_eq!(origin_title(&url), "localhost:3000"); + } + + #[test] + fn open_url_must_be_a_web_page() { + assert!(parse_web_url(" https://example.com ").is_ok()); + assert!(parse_web_url("about:blank").is_ok()); + assert!(parse_web_url("file:///etc/hosts").is_err()); + assert!(parse_web_url("javascript:1").is_err()); + assert!(parse_web_url("not a url").is_err()); + } + + #[test] + fn capabilities_report_a_surface() { + let caps = capabilities(); + assert!(caps.available); + assert!(caps.surface.is_some()); + assert!(!caps.platform.is_empty()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6b6e316fc7..77f31fe12a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,8 @@ pub mod app_update; pub mod automation; pub mod background; pub mod backup; +#[cfg(feature = "tauri-runtime")] +pub mod browser; pub mod canvas; pub mod chat_authoring; pub mod chat_channel; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b692404f5..3d57f737a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ mod app_error; pub mod app_state; pub mod automation; pub mod backgrounds; +#[cfg(feature = "tauri-runtime")] +pub mod browser; pub mod chat_channel; pub mod commands; pub mod db; @@ -64,6 +66,7 @@ mod tauri_app { use crate::commands::{ acp as acp_commands, app_update as app_update_commands, automation as automation_commands, background as background_commands, backup, + browser as browser_commands, canvas as canvas_commands, chat_authoring as chat_authoring_commands, chat_channel as chat_channel_commands, conversations, @@ -390,6 +393,7 @@ mod tauri_app { None, )) .manage(ConnectionManager::new()) + .manage(crate::browser::BrowserRegistry::default()) .manage(TerminalManager::new()) .manage(ChatChannelManager::new()) .manage(windows::SettingsWindowState::new()) @@ -994,6 +998,15 @@ mod tauri_app { } } + #[cfg(all( + feature = "browser-child", + any(target_os = "macos", target_os = "windows") + ))] + crate::browser::surface_child::init_main_thread(); + + #[cfg(feature = "browser-smoke")] + crate::browser::smoke::spawn_if_enabled(app.handle().clone()); + Ok(()) }) .on_menu_event(|app, event| { @@ -1038,6 +1051,12 @@ mod tauri_app { .on_window_event(|window, event| { let label = window.label().to_string(); + // A window's browser tabs die with it: child webviews are + // destroyed by the platform, owned windows are closed here. + if matches!(event, tauri::WindowEvent::Destroyed) { + browser_commands::close_all_for_owner(window.app_handle(), &label); + } + if (label == "settings" || label.starts_with("remote-settings-")) && matches!( event, @@ -1174,6 +1193,15 @@ mod tauri_app { } }) .invoke_handler(tauri::generate_handler![ + browser_commands::browser_capabilities, + browser_commands::browser_open_tab, + browser_commands::browser_close, + browser_commands::browser_set_bounds, + browser_commands::browser_set_visible, + browser_commands::browser_navigate, + browser_commands::browser_reload, + browser_commands::browser_get_state, + browser_commands::browser_list_tabs, conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, From e1ab17106a1b901d76b100fcf36b26ced3ddc4d1 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 7 Sep 2026 16:02:31 +0800 Subject: [PATCH 04/79] feat(browser): isolated-world channel, macOS WebKit shim and popup adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every embedded tab now gets a helper script in a WebKit content world named "codeg" plus a native message handler (objc2), installed before the first document loads: the webview is built with no URL and navigated only after the channel is in place, which also keeps about:blank out of the tab's history. The helper reports SPA navigation and document titles the engine does not surface, and records user gestures (click / auxclick / keydown with the anchor found along composedPath) for the popup policy. Page scripts cannot see the world, and prototype tampering in the page world does not affect it. The shim also provides world-scoped evaluation, PNG snapshots (takeSnapshotWithConfiguration) and back / forward / stop, wired through the surface enum and new browser_go_back / browser_go_forward / browser_stop commands. Page-initiated new windows go through wry's new_window_req_handler: a request without a user gesture in the last second is denied (popup blocker); everything else is answered with NewWindowResponse::Create using a child webview built from the opener's WKWebViewConfiguration, so window.opener, referrer and postMessage keep working while the host only decides presentation — the popup is adopted as a tab next to its opener and announced on browser://popup. A popup shares its opener's user-content controller, so world installation is idempotent per controller and messages are attributed to tabs by source webview. The helper no longer opens middle-clicked links itself: WebKit already treats a middle-button auxclick on an anchor as a link click and asks for a new window, so doing it in script produced two tabs. --- src-tauri/Cargo.lock | 5 + src-tauri/Cargo.toml | 8 + src-tauri/src/browser/channel.rs | 159 ++++++++++ src-tauri/src/browser/events.rs | 9 +- src-tauri/src/browser/hooks.rs | 19 ++ src-tauri/src/browser/mod.rs | 5 + src-tauri/src/browser/registry.rs | 51 ++- src-tauri/src/browser/shim/macos.rs | 264 ++++++++++++++++ src-tauri/src/browser/shim/mod.rs | 9 + src-tauri/src/browser/smoke.rs | 56 ++++ src-tauri/src/browser/surface.rs | 54 +++- src-tauri/src/browser/surface_child.rs | 414 ++++++++++++++++++++++--- src-tauri/src/browser/types.rs | 27 ++ src-tauri/src/commands/browser.rs | 89 +++++- src-tauri/src/lib.rs | 3 + src/browser-injected/helper.js | 237 ++++++++++++++ 16 files changed, 1352 insertions(+), 57 deletions(-) create mode 100644 src-tauri/src/browser/channel.rs create mode 100644 src-tauri/src/browser/shim/macos.rs create mode 100644 src-tauri/src/browser/shim/mod.rs create mode 100644 src/browser-injected/helper.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 10987999d1..73a66054a8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1051,6 +1051,7 @@ dependencies = [ "axum", "axum-test", "base64 0.22.1", + "block2", "bzip2", "chrono", "chrono-tz", @@ -1076,6 +1077,10 @@ dependencies = [ "mac-notification-sys", "minisign-verify", "notify", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-web-kit", "portable-pty", "prost", "qrcode", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d8062ecec..358bb1a72d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -176,6 +176,14 @@ tauri = { version = "2.11", features = ["devtools"], optional = true } [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "0.6" +# Built-in browser macOS shim (isolated world, world-scoped eval, snapshots, +# back/forward). Versions track the ones wry 0.55 uses so the types line up +# with the handles wry hands out. +objc2 = "0.6" +block2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread"] } +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Threading"] } diff --git a/src-tauri/src/browser/channel.rs b/src-tauri/src/browser/channel.rs new file mode 100644 index 0000000000..4dc9871494 --- /dev/null +++ b/src-tauri/src/browser/channel.rs @@ -0,0 +1,159 @@ +//! Page → host channel: the helper script (`src/browser-injected/helper.js`) +//! runs in an isolated world and posts JSON envelopes through a native +//! message handler; this module validates and routes them. Everything that +//! arrives here is page-controlled input: sizes are capped, unknown kinds +//! are dropped, and gestures are forwarded to the frontend flagged +//! `untrusted`. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager, Url}; + +use crate::web::event_bridge::{emit_event, EventEmitter}; + +use super::events; +use super::hooks; +use super::registry::BrowserRegistry; +use super::types::ChannelKind; + +pub const HELPER_JS: &str = include_str!("../../../src/browser-injected/helper.js"); +pub const MAX_MESSAGE_BYTES: usize = 64 * 1024; +pub const TELEMETRY_EVENT: &str = "browser://telemetry"; + +/// `(message, is_main_frame, source webview pointer)`. The pointer identifies +/// the webview the message came from: a popup created from an opener shares +/// the opener's user-content controller (and therefore its message handler), +/// so the handler cannot be bound to one tab. +pub type MessageSink = Arc; + +/// Defines the send primitive the helper calls; injected before the helper, +/// in the same world, so the page never sees either. +#[cfg(target_os = "macos")] +pub const PREFIX_SCRIPT: &str = "globalThis.__codegSend = function (m) { window.webkit.messageHandlers.codegBrowser.postMessage(String(m)); };"; + +#[derive(Debug, Deserialize)] +pub struct Envelope { + pub kind: String, + #[serde(default)] + pub payload: Value, + /// Set by the helper when it runs in the top-level browsing context. + #[serde(default)] + pub top: bool, +} + +pub fn parse_envelope(raw: &str) -> Option { + if raw.len() > MAX_MESSAGE_BYTES { + return None; + } + serde_json::from_str::(raw).ok() +} + +pub fn handle_message(app: &AppHandle, tab_id: &str, raw: String, main_frame: bool) { + let Some(envelope) = parse_envelope(&raw) else { + tracing::warn!( + "[browser] tab {tab_id}: dropped malformed or oversized channel message ({} bytes)", + raw.len() + ); + return; + }; + let Some(registry) = app.try_state::() else { + return; + }; + match envelope.kind.as_str() { + "hello" => { + if !(main_frame && envelope.top) { + return; + } + let state = registry.update_state(tab_id, |state| { + if state.channel != ChannelKind::Legacy { + state.channel = ChannelKind::Native; + } + }); + if let Some(state) = state { + events::emit_state(app, &state); + } + } + "nav-state" => { + if !(main_frame && envelope.top) { + return; + } + let href = envelope + .payload + .get("href") + .and_then(Value::as_str) + .and_then(|h| Url::parse(h).ok()); + let title = envelope + .payload + .get("title") + .and_then(Value::as_str) + .map(str::to_string); + let changed = registry.update(tab_id, |tab| { + let mut changed = false; + if let Some(url) = &href { + let text = url.to_string(); + if tab.state.url != text { + tab.state.url = text; + tab.state.origin = hooks::origin_of(url); + changed = true; + } + } + if let Some(title) = title { + if tab.state.title != title { + tab.state.title = title; + changed = true; + } + } + changed.then(|| tab.state.clone()) + }); + if let Some(Some(state)) = changed { + events::emit_state(app, &state); + } + } + "gesture" => { + registry.push_gesture(tab_id, envelope.payload.clone()); + emit_event( + &EventEmitter::Tauri(app.clone()), + TELEMETRY_EVENT, + json!({ + "tabId": tab_id, + "kind": "gesture", + "untrusted": true, + "mainFrame": main_frame, + "top": envelope.top, + "payload": envelope.payload, + }), + ); + } + other => { + tracing::debug!("[browser] tab {tab_id}: ignored channel message kind {other:?}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_parsing_is_strict_about_size_and_shape() { + let ok = parse_envelope(r#"{"kind":"hello","payload":{"href":"x"},"top":true}"#).unwrap(); + assert_eq!(ok.kind, "hello"); + assert!(ok.top); + let minimal = parse_envelope(r#"{"kind":"gesture"}"#).unwrap(); + assert!(!minimal.top); + assert!(minimal.payload.is_null()); + assert!(parse_envelope("not json").is_none()); + assert!(parse_envelope(r#"{"payload":{}}"#).is_none()); + let huge = format!(r#"{{"kind":"x","payload":"{}"}}"#, "a".repeat(MAX_MESSAGE_BYTES)); + assert!(parse_envelope(&huge).is_none()); + } + + #[test] + fn helper_is_bundled_and_self_contained() { + assert!(HELPER_JS.contains("codegBrowserHelper")); + assert!(!HELPER_JS.contains("import ")); + assert!(!HELPER_JS.contains("require(")); + } +} diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index d603cadd16..03ca06459c 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -5,7 +5,10 @@ use tauri::AppHandle; use crate::web::event_bridge::{emit_event, EventEmitter}; -use super::types::{BrowserClosedPayload, BrowserTabState, CLOSED_EVENT, STATE_EVENT}; +use super::types::{ + BrowserClosedPayload, BrowserPopupPayload, BrowserTabState, CLOSED_EVENT, POPUP_EVENT, + STATE_EVENT, +}; pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { emit_event(&EventEmitter::Tauri(app.clone()), STATE_EVENT, state); @@ -21,3 +24,7 @@ pub fn emit_closed(app: &AppHandle, tab_id: &str, owner_window: &str) { }, ); } + +pub fn emit_popup(app: &AppHandle, payload: &BrowserPopupPayload) { + emit_event(&EventEmitter::Tauri(app.clone()), POPUP_EVENT, payload); +} diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index 35eb514763..c55ff7685a 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -20,6 +20,21 @@ pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { let Some(registry) = app.try_state::() else { return; }; + // History flags are only trustworthy once the navigation committed; the + // surface call runs inline here (main thread) and never takes the + // registry lock itself. + let history = if started { + None + } else { + registry + .surface(tab_id) + .map(|surface| { + ( + surface.can_go_back().unwrap_or(false), + surface.can_go_forward().unwrap_or(false), + ) + }) + }; let state = registry.update_state(tab_id, |state| { state.url = url.to_string(); state.loading = started; @@ -30,6 +45,10 @@ pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { // toolbar falls back to the host until `title_changed` fires. state.title.clear(); } + if let Some((back, forward)) = history { + state.can_go_back = back; + state.can_go_forward = forward; + } }); if let Some(state) = state { events::emit_state(app, &state); diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index eee0ea0142..6cfeeaccfb 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -16,11 +16,14 @@ //! - `registry` — tab id → surface + last known state //! - `surface` — the enum over the concrete surfaces and their common ops //! - `surface_child` / `surface_window` — the concrete builders +//! - `channel` — page → host messages from the isolated-world helper +//! - `shim` — per-platform WebKit / WebView2 calls (worlds, eval, snapshot) //! - `hooks` — webview callbacks (page load, title) → registry + events //! - `events` — state fan-out to the frontend //! - `smoke` — dev-only puppet driven by a JSON control file (feature //! `browser-smoke`, never in a release build) +pub mod channel; pub mod events; pub mod hooks; pub mod policy; @@ -34,6 +37,8 @@ pub mod surface_child; pub mod surface_window; pub mod types; +pub mod shim; + #[cfg(feature = "browser-smoke")] pub mod smoke; diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 4bcf4fd0c9..057f5cdd5c 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -2,14 +2,30 @@ //! webview hooks and the window-close cleanup. The mutex is only ever held //! for map operations; every surface call happens on a clone taken out of it. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::{Mutex, MutexGuard}; +use std::time::Instant; + +use serde_json::Value; use crate::app_error::AppCommandError; use super::surface::BrowserSurface; use super::types::{Bounds, BrowserTabState}; +/// Recent user gestures reported by the isolated-world helper (untrusted). +/// Consumed by the popup router to tell a gesture-backed `window.open` from +/// an unsolicited one and to match modifier-clicks against navigations. +#[derive(Debug, Clone)] +pub struct GestureRecord { + pub received: Instant, + pub payload: Value, +} + +/// How many gestures to remember per tab; a click storm never needs more +/// than the last few, and the popup rule only looks one second back. +pub const GESTURE_RING_CAPACITY: usize = 16; + pub struct BrowserTab { pub state: BrowserTabState, pub surface: BrowserSurface, @@ -17,6 +33,19 @@ pub struct BrowserTab { /// shown again after being hidden. pub last_bounds: Bounds, pub visible: bool, + pub gestures: VecDeque, +} + +impl BrowserTab { + pub fn new(state: BrowserTabState, surface: BrowserSurface, bounds: Bounds, visible: bool) -> Self { + Self { + state, + surface, + last_bounds: bounds, + visible, + gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), + } + } } #[derive(Default)] @@ -109,6 +138,26 @@ impl BrowserRegistry { ids.into_iter().filter_map(|id| tabs.remove(&id)).collect() } + pub fn push_gesture(&self, tab_id: &str, payload: Value) { + self.update(tab_id, |tab| { + if tab.gestures.len() == GESTURE_RING_CAPACITY { + tab.gestures.pop_front(); + } + tab.gestures.push_back(GestureRecord { + received: Instant::now(), + payload, + }); + }); + } + + /// Newest first. + pub fn recent_gestures(&self, tab_id: &str) -> Vec { + self.lock() + .get(tab_id) + .map(|tab| tab.gestures.iter().rev().cloned().collect()) + .unwrap_or_default() + } + pub fn len(&self) -> usize { self.lock().len() } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs new file mode 100644 index 0000000000..8f4027c799 --- /dev/null +++ b/src-tauri/src/browser/shim/macos.rs @@ -0,0 +1,264 @@ +//! macOS shim on top of `WKWebView` / `WKUserContentController` (objc2). +//! +//! The helper script and the `codegBrowser` message handler live in the +//! `WKContentWorld` named `codeg`: a separate JavaScript global for the same +//! DOM, invisible to page scripts and immune to their prototype tampering. +//! `WKContentWorld` needs macOS 11; older systems fall back to the page world +//! (reported as `ChannelKind::Legacy`). + +use std::cell::RefCell; +use std::collections::HashSet; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject}; +use objc2::{define_class, msg_send, sel, DeclaredClass, MainThreadMarker, MainThreadOnly}; +use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; +use objc2_foundation::{ns_string, NSDictionary, NSError, NSString}; +use objc2_web_kit::{ + WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKSnapshotConfiguration, + WKUserContentController, WKUserScript, WKUserScriptInjectionTime, +}; +use tauri_runtime_wry::wry::{self, WebViewExtMacOS}; + +use super::super::channel::MessageSink; + +pub const WORLD_NAME: &str = "codeg"; +pub const HANDLER_NAME: &str = "codegBrowser"; + +thread_local! { + // Controllers that already carry our handler + scripts. A popup created + // from an opener arrives with the opener's WKUserContentController, and + // WebKit throws on a second handler registration under the same name. + static INSTALLED_CONTROLLERS: RefCell> = RefCell::new(HashSet::new()); +} + +pub struct HandlerIvars { + sink: MessageSink, +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[ivars = HandlerIvars] + pub struct CodegMessageHandler; + + unsafe impl NSObjectProtocol for CodegMessageHandler {} + + unsafe impl WKScriptMessageHandler for CodegMessageHandler { + #[unsafe(method(userContentController:didReceiveScriptMessage:))] + fn did_receive( + this: &CodegMessageHandler, + _controller: &WKUserContentController, + message: &WKScriptMessage, + ) { + // SAFETY: WebKit hands us live objects on the main thread. + unsafe { + let body = message.body(); + let Some(text) = body.downcast_ref::() else { + return; + }; + let frame = message.frameInfo(); + let main_frame = frame.isMainFrame(); + let source = frame + .webView() + .map(|wv| Retained::as_ptr(&wv) as usize) + .unwrap_or(0); + (this.ivars().sink)(text.to_string(), main_frame, source); + } + } + } +); + +impl CodegMessageHandler { + fn new(sink: MessageSink, mtm: MainThreadMarker) -> Retained { + let this = mtm.alloc::().set_ivars(HandlerIvars { sink }); + // SAFETY: plain NSObject init. + unsafe { msg_send![super(this), init] } + } +} + +fn mtm() -> Result { + MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string()) +} + +/// Content worlds (macOS 11+): decided at runtime, not compile time, because +/// codeg sets no minimumSystemVersion. +pub fn supports_content_world(controller: &WKUserContentController) -> bool { + controller.respondsToSelector(sel!(addScriptMessageHandler:contentWorld:name:)) +} + +/// Register the message handler and inject `scripts` at document start in +/// every frame. Returns `true` when an isolated world was used. Idempotent per +/// user-content controller. +pub fn install_world( + webview: &wry::WebView, + scripts: &[&str], + sink: MessageSink, +) -> Result { + let mtm = mtm()?; + let controller = webview.manager(); + let key = Retained::as_ptr(&controller) as usize; + let already = INSTALLED_CONTROLLERS.with(|set| !set.borrow_mut().insert(key)); + if already { + // Shared controller (popup from an opener): scripts and handler are in + // place already, and the sink resolves the tab per message. + return Ok(supports_content_world(&controller)); + } + let handler = CodegMessageHandler::new(sink, mtm); + let proto = ProtocolObject::from_ref(&*handler); + // SAFETY: main thread, live controller; WebKit retains the handler. + unsafe { + if supports_content_world(&controller) { + let world = WKContentWorld::worldWithName(ns_string!("codeg"), mtm); + controller.addScriptMessageHandler_contentWorld_name( + proto, + &world, + ns_string!("codegBrowser"), + ); + for source in scripts { + let script = WKUserScript::initWithSource_injectionTime_forMainFrameOnly_inContentWorld( + mtm.alloc(), + &NSString::from_str(source), + WKUserScriptInjectionTime::AtDocumentStart, + false, + &world, + ); + controller.addUserScript(&script); + } + Ok(true) + } else { + controller.addScriptMessageHandler_name(proto, ns_string!("codegBrowser")); + for source in scripts { + let script = WKUserScript::initWithSource_injectionTime_forMainFrameOnly( + mtm.alloc(), + &NSString::from_str(source), + WKUserScriptInjectionTime::AtDocumentStart, + false, + ); + controller.addUserScript(&script); + } + Ok(false) + } + } +} + +/// Evaluate `expression` in the `codeg` world of the main frame. The result +/// arrives as the JSON string `{"ok":true,"value":…}` or +/// `{"ok":false,"error":…}` so no platform value conversion is needed. +pub fn eval_in_world( + webview: &wry::WebView, + expression: &str, + callback: impl Fn(Result) + Send + 'static, +) -> Result<(), String> { + let mtm = mtm()?; + let wk = webview.webview(); + let wrapped = format!( + "(function(){{try{{return JSON.stringify({{ok:true,value:(function(){{return ({expression});}})()}})}}catch(e){{return JSON.stringify({{ok:false,error:String(e&&e.stack||e)}})}}}})()" + ); + let block = RcBlock::::new( + move |result: *mut AnyObject, error: *mut NSError| { + // SAFETY: WebKit passes valid or null pointers; we only read. + let outcome = unsafe { + if !error.is_null() { + Err((*error).localizedDescription().to_string()) + } else if result.is_null() { + Ok("null".to_string()) + } else { + (*result) + .downcast_ref::() + .map(|s| s.to_string()) + .ok_or_else(|| "non-string result".to_string()) + } + }; + callback(outcome); + }, + ); + // SAFETY: main thread, live webview. + unsafe { + let world = WKContentWorld::worldWithName(ns_string!("codeg"), mtm); + wk.evaluateJavaScript_inFrame_inContentWorld_completionHandler( + &NSString::from_str(&wrapped), + None, + &world, + Some(&block), + ); + } + Ok(()) +} + +/// Viewport snapshot as PNG bytes. +pub fn snapshot_png( + webview: &wry::WebView, + callback: impl Fn(Result, String>) + Send + 'static, +) -> Result<(), String> { + let mtm = mtm()?; + let wk = webview.webview(); + let block = RcBlock::::new( + move |image: *mut NSImage, error: *mut NSError| { + // SAFETY: WebKit passes valid or null pointers; we only read. + let outcome = unsafe { + if !error.is_null() { + Err((*error).localizedDescription().to_string()) + } else if image.is_null() { + Err("snapshot returned no image".to_string()) + } else { + (*image) + .TIFFRepresentation() + .and_then(|tiff| NSBitmapImageRep::imageRepWithData(&tiff)) + .and_then(|rep| { + rep.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + }) + .map(|png| png.to_vec()) + .ok_or_else(|| "png encoding failed".to_string()) + } + }; + callback(outcome); + }, + ); + // SAFETY: main thread, live webview. + unsafe { + let config = WKSnapshotConfiguration::new(mtm); + config.setAfterScreenUpdates(true); + wk.takeSnapshotWithConfiguration_completionHandler(Some(&config), &block); + } + Ok(()) +} + +pub fn go_back(webview: &wry::WebView) { + // SAFETY: main thread, live webview. + unsafe { + let _ = webview.webview().goBack(); + } +} + +pub fn go_forward(webview: &wry::WebView) { + // SAFETY: main thread, live webview. + unsafe { + let _ = webview.webview().goForward(); + } +} + +pub fn can_go_back(webview: &wry::WebView) -> bool { + // SAFETY: main thread, live webview. + unsafe { webview.webview().canGoBack() } +} + +pub fn can_go_forward(webview: &wry::WebView) -> bool { + // SAFETY: main thread, live webview. + unsafe { webview.webview().canGoForward() } +} + +pub fn stop_loading(webview: &wry::WebView) { + // SAFETY: main thread, live webview. + unsafe { webview.webview().stopLoading() } +} + +/// Identity of the platform webview behind a wry `WebView`, matching the +/// `source` a message sink receives. +pub fn webview_pointer(webview: &wry::WebView) -> usize { + Retained::as_ptr(&webview.webview()) as usize +} diff --git a/src-tauri/src/browser/shim/mod.rs b/src-tauri/src/browser/shim/mod.rs new file mode 100644 index 0000000000..835b64f983 --- /dev/null +++ b/src-tauri/src/browser/shim/mod.rs @@ -0,0 +1,9 @@ +//! Platform-specific WebKit / WebView2 calls that neither tauri nor wry +//! expose: isolated-world scripts and message handlers, world-scoped +//! evaluation, snapshots, back / forward. Every function here runs on the +//! main thread against the live platform webview and is only ever reached +//! through `surface_child::ChildHandle` (or, later, `with_webview` for owned +//! windows). + +#[cfg(target_os = "macos")] +pub mod macos; diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 79521485ef..a298f8aea5 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -305,6 +305,62 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result { .map_err(|_| "eval timed out".to_string())?; Ok(serde_json::from_str(&value).unwrap_or(Value::String(value))) } + "browser_eval_world" => { + let surface = registry + .surface(&str_arg(cmd, "tab_id")?) + .ok_or("no such tab")?; + let js = str_arg(cmd, "js")?; + let (tx, rx) = std::sync::mpsc::channel::>(); + surface + .eval_in_world(&js, move |value| { + let _ = tx.send(value); + }) + .map_err(err_string)?; + let timeout = Duration::from_millis(cmd.get("timeout_ms").and_then(Value::as_u64).unwrap_or(8000)); + let value = tokio::task::spawn_blocking(move || rx.recv_timeout(timeout)) + .await + .map_err(err_string)? + .map_err(|_| "world eval timed out".to_string())??; + Ok(serde_json::from_str(&value).unwrap_or(Value::String(value))) + } + "browser_snapshot" => { + let surface = registry + .surface(&str_arg(cmd, "tab_id")?) + .ok_or("no such tab")?; + let path = str_arg(cmd, "path")?; + let (tx, rx) = std::sync::mpsc::channel::, String>>(); + surface + .snapshot_png(move |png| { + let _ = tx.send(png); + }) + .map_err(err_string)?; + let png = tokio::task::spawn_blocking(move || rx.recv_timeout(Duration::from_secs(10))) + .await + .map_err(err_string)? + .map_err(|_| "snapshot timed out".to_string())??; + std::fs::write(&path, &png).map_err(err_string)?; + Ok(json!({ "path": path, "bytes": png.len() })) + } + "browser_back" => { + browser_commands::go_back_core(®istry, &str_arg(cmd, "tab_id")?).map_err(err_string)?; + Ok(Value::Null) + } + "browser_forward" => { + browser_commands::go_forward_core(®istry, &str_arg(cmd, "tab_id")?).map_err(err_string)?; + Ok(Value::Null) + } + "browser_stop" => { + browser_commands::stop_core(app, ®istry, &str_arg(cmd, "tab_id")?).map_err(err_string)?; + Ok(Value::Null) + } + "browser_gestures" => { + let gestures: Vec = registry + .recent_gestures(&str_arg(cmd, "tab_id")?) + .into_iter() + .map(|g| json!({ "age_ms": g.received.elapsed().as_millis() as u64, "payload": g.payload })) + .collect(); + Ok(Value::Array(gestures)) + } "browser_focus" => { let surface = registry .surface(&str_arg(cmd, "tab_id")?) diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index 89649c6207..4a9bbb4d41 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -42,7 +42,8 @@ pub enum BrowserSurface { any(target_os = "macos", target_os = "windows") ))] Child(ChildHandle), - Window(tauri::WebviewWindow), + /// Boxed: `WebviewWindow` is ~900 bytes and the enum is cloned around. + Window(Box), } // The child arm is compiled out on Linux; the macro keeps every method to one @@ -127,6 +128,57 @@ impl BrowserSurface { window: |_w| { let _ = bounds; Ok(()) }) } + pub fn install_channel(&self) -> Result { + per_surface!(self, + child: |c| Ok(c.install_channel()?), + window: |_w| Err(SurfaceError("page channel for owned windows lands with the platform shims".into()))) + } + + pub fn eval_in_world( + &self, + expression: &str, + callback: impl Fn(Result) + Send + 'static, + ) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.eval_in_world(expression, callback)?), + window: |_w| { let _ = (expression, callback); Err(SurfaceError("world evaluation for owned windows lands with the platform shims".into())) }) + } + + pub fn snapshot_png( + &self, + callback: impl Fn(Result, String>) + Send + 'static, + ) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.snapshot_png(callback)?), + window: |_w| { let _ = callback; Err(SurfaceError("snapshots for owned windows land with the platform shims".into())) }) + } + + pub fn go_back(&self) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.go_back()?), + window: |_w| Err(SurfaceError("history navigation for owned windows lands with the platform shims".into()))) + } + + pub fn go_forward(&self) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.go_forward()?), + window: |_w| Err(SurfaceError("history navigation for owned windows lands with the platform shims".into()))) + } + + pub fn can_go_back(&self) -> Result { + per_surface!(self, child: |c| Ok(c.can_go_back()?), window: |_w| Ok(false)) + } + + pub fn can_go_forward(&self) -> Result { + per_surface!(self, child: |c| Ok(c.can_go_forward()?), window: |_w| Ok(false)) + } + + pub fn stop(&self) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.stop()?), + window: |_w| Err(SurfaceError("stop for owned windows lands with the platform shims".into()))) + } + pub fn set_zoom(&self, factor: f64) -> Result<(), SurfaceError> { per_surface!(self, child: |c| Ok(c.zoom(factor)?), window: |w| Ok(w.set_zoom(factor)?)) } diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 9cdfdaded0..f9b4de5d57 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -11,25 +11,48 @@ //! caller already is the main thread (wry handlers, window-event hooks). //! Never call into a handle while holding the registry mutex — the main //! thread may need that mutex to finish the very operation being waited on. +//! +//! Page-initiated new windows (`window.open`, `target=_blank`) are handled +//! here too: the engine asks for a webview, we build one from the opener's +//! configuration (which is what keeps `window.opener` alive) and hand it back +//! with `NewWindowResponse::Create`, registering it as a new tab next to the +//! opener. The host never navigates on the page's behalf. use std::cell::RefCell; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::thread::ThreadId; +use std::time::Duration; -use tauri::{AppHandle, Url, WebviewWindow}; -use tauri_runtime_wry::wry::{self, dpi, PageLoadEvent, Rect, WebViewBuilder}; +use tauri::{AppHandle, Manager, Url, WebviewWindow}; +use tauri_runtime_wry::wry::{ + self, dpi, NewWindowFeatures, NewWindowResponse, PageLoadEvent, Rect, WebViewBuilder, +}; +use super::channel::{self, MessageSink}; +use super::events; use super::hooks; use super::policy; -use super::types::Bounds; +use super::registry::{BrowserRegistry, BrowserTab}; +use super::surface::BrowserSurface; +use super::types::{ + Bounds, BrowserPopupPayload, BrowserTabState, ChannelKind, PopupPresentation, SurfaceKind, +}; +#[cfg(target_os = "macos")] +use super::shim::macos as shim; thread_local! { static SURFACES: RefCell> = RefCell::new(HashMap::new()); } static MAIN_THREAD: OnceLock = OnceLock::new(); +static POPUP_SEQ: AtomicU64 = AtomicU64::new(0); + +/// How far back a page-initiated new-window request may look for a user +/// gesture before it counts as an unsolicited popup. +pub const POPUP_GESTURE_WINDOW: Duration = Duration::from_secs(1); /// Must be called once from the main thread (tauri's `setup` hook) before any /// surface is created; lets `ChildHandle` run inline instead of deadlocking @@ -74,6 +97,29 @@ fn rect(bounds: Bounds) -> Rect { } } +/// Main thread only: which tab owns the platform webview behind `pointer` +/// (see `channel::MessageSink`). +#[cfg(target_os = "macos")] +fn tab_id_for_webview(pointer: usize) -> Option { + SURFACES.with(|s| { + s.borrow() + .iter() + .find(|(_, wv)| shim::webview_pointer(wv) == pointer) + .map(|(id, _)| id.clone()) + }) +} + +/// One sink for every tab: messages are attributed by source webview, because +/// an adopted popup shares its opener's user-content controller. +#[cfg(target_os = "macos")] +fn message_sink(app: &AppHandle) -> MessageSink { + let app = app.clone(); + Arc::new(move |raw, main_frame, source| match tab_id_for_webview(source) { + Some(tab_id) => channel::handle_message(&app, &tab_id, raw, main_frame), + None => tracing::debug!("[browser] channel message from an unknown webview dropped"), + }) +} + #[derive(Clone, Debug)] pub struct ChildHandle { tab_id: String, @@ -158,6 +204,126 @@ impl ChildHandle { self.op(|wv| wv.clear_all_browsing_data()) } + /// Install the isolated-world helper and the native message handler. + /// `Ok(true)` = isolated world, `Ok(false)` = page-world fallback. + pub fn install_channel(&self) -> Result { + #[cfg(target_os = "macos")] + { + let sink = message_sink(&self.app); + self.with(move |wv| { + shim::install_world(wv, &[channel::PREFIX_SCRIPT, channel::HELPER_JS], sink) + })? + .map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "page channel is not implemented on this platform yet".into(), + )) + } + } + + /// Evaluate an expression in the helper's world; see `shim::eval_in_world` + /// for the result envelope. + pub fn eval_in_world( + &self, + expression: &str, + callback: impl Fn(Result) + Send + 'static, + ) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + let expression = expression.to_string(); + self.with(move |wv| shim::eval_in_world(wv, &expression, callback))? + .map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (expression, callback); + Err(ChildError::Op( + "world evaluation is not implemented on this platform yet".into(), + )) + } + } + + pub fn snapshot_png( + &self, + callback: impl Fn(Result, String>) + Send + 'static, + ) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(move |wv| shim::snapshot_png(wv, callback))? + .map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + let _ = callback; + Err(ChildError::Op( + "snapshots are not implemented on this platform yet".into(), + )) + } + } + + pub fn go_back(&self) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(shim::go_back) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "history navigation is not implemented on this platform yet".into(), + )) + } + } + + pub fn go_forward(&self) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(shim::go_forward) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "history navigation is not implemented on this platform yet".into(), + )) + } + } + + pub fn can_go_back(&self) -> Result { + #[cfg(target_os = "macos")] + { + self.with(shim::can_go_back) + } + #[cfg(not(target_os = "macos"))] + { + Ok(false) + } + } + + pub fn can_go_forward(&self) -> Result { + #[cfg(target_os = "macos")] + { + self.with(shim::can_go_forward) + } + #[cfg(not(target_os = "macos"))] + { + Ok(false) + } + } + + pub fn stop(&self) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(shim::stop_loading) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "stop is not implemented on this platform yet".into(), + )) + } + } + /// Detach and drop the webview (on the main thread; wry removes the /// native view from the window when the `WebView` drops). pub fn close(&self) -> Result<(), ChildError> { @@ -173,8 +339,71 @@ impl ChildHandle { } } -/// Build the child webview on `about:blank` at `bounds`. The caller navigates -/// afterwards, once the page ↔ host channel is installed. +/// Opener-provided platform configuration for a popup webview. +#[cfg(target_os = "macos")] +type OpenerConfiguration = objc2::rc::Retained; +#[cfg(not(target_os = "macos"))] +type OpenerConfiguration = (); + +/// Main thread only. Builds the child webview at `bounds` with every hook +/// attached and **no URL**: a regular tab is navigated by the caller once the +/// page channel is installed, a popup is navigated by the engine itself. +fn build_child( + app: &AppHandle, + owner: &WebviewWindow, + tab_id: &str, + label: &str, + bounds: Bounds, + visible: bool, + configuration: Option, +) -> Result { + let nav_id = tab_id.to_string(); + #[allow(unused_mut)] + let mut builder = WebViewBuilder::new() + .with_id(label) + .with_bounds(rect(bounds)) + .with_visible(visible) + .with_focused(false) + .with_devtools(true) + .with_hotkeys_zoom(true) + .with_navigation_handler(move |url| { + let allowed = Url::parse(&url) + .map(|u| policy::navigation_allowed(&u)) + .unwrap_or(false); + if !allowed { + tracing::info!("[browser] tab {nav_id} blocked navigation to {url}"); + } + allowed + }) + .with_on_page_load_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |event, url| { + if let Ok(url) = Url::parse(&url) { + hooks::page_load(&app, &id, &url, matches!(event, PageLoadEvent::Started)); + } + } + }) + .with_document_title_changed_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |title| hooks::title_changed(&app, &id, title) + }) + // Downloads are refused until the download UI exists (P2). + .with_download_started_handler(|_url, _destination| false) + .with_new_window_req_handler(new_window_handler(app.clone(), owner.clone(), tab_id.to_string())); + #[cfg(target_os = "macos")] + if let Some(configuration) = configuration { + use tauri_runtime_wry::wry::WebViewBuilderExtMacos; + builder = builder.with_webview_configuration(configuration); + } + #[cfg(not(target_os = "macos"))] + let _ = configuration; + builder.build_as_child(owner).map_err(|e| e.to_string()) +} + +/// Build the child webview for a regular tab. The caller navigates afterwards, +/// once the page ↔ host channel is installed. pub fn create( app: &AppHandle, owner: &WebviewWindow, @@ -188,51 +417,146 @@ pub fn create( label: label.to_string(), app: app.clone(), }; + let app = app.clone(); let owner = owner.clone(); - let app_for_hooks = app.clone(); let id = tab_id.to_string(); let label = label.to_string(); - run_on_main(app, move || -> Result<(), String> { - let nav_id = id.clone(); - let builder = WebViewBuilder::new() - .with_id(&label) - .with_url("about:blank") - .with_bounds(rect(bounds)) - .with_visible(!background) - .with_focused(false) - .with_devtools(true) - .with_hotkeys_zoom(true) - .with_navigation_handler(move |url| { - let allowed = Url::parse(&url) - .map(|u| policy::navigation_allowed(&u)) - .unwrap_or(false); - if !allowed { - tracing::info!("[browser] tab {nav_id} blocked navigation to {url}"); - } - allowed - }) - .with_on_page_load_handler({ - let app = app_for_hooks.clone(); - let id = id.clone(); - move |event, url| { - if let Ok(url) = Url::parse(&url) { - hooks::page_load(&app, &id, &url, matches!(event, PageLoadEvent::Started)); - } - } - }) - .with_document_title_changed_handler({ - let app = app_for_hooks.clone(); - let id = id.clone(); - move |title| hooks::title_changed(&app, &id, title) - }) - // Downloads are refused until the download UI exists (P2). - .with_download_started_handler(|_url, _destination| false); - let webview = builder - .build_as_child(&owner) - .map_err(|e| e.to_string())?; + run_on_main(&app.clone(), move || -> Result<(), String> { + let webview = build_child(&app, &owner, &id, &label, bounds, !background, None)?; SURFACES.with(|s| s.borrow_mut().insert(id, webview)); Ok(()) })? .map_err(ChildError::Op)?; Ok(handle) } + +fn deny(app: &AppHandle, opener_tab_id: &str, url: &str, features: &NewWindowFeatures, reason: &str) -> NewWindowResponse { + tracing::info!("[browser] tab {opener_tab_id}: new-window request for {url} denied ({reason})"); + events::emit_popup( + app, + &BrowserPopupPayload { + presentation: PopupPresentation::Denied, + opener_tab_id: opener_tab_id.to_string(), + tab_id: None, + url: url.to_string(), + requested_size: features.size.map(|s| [s.width, s.height]), + reason: Some(reason.to_string()), + }, + ); + NewWindowResponse::Deny +} + +/// wry calls this on the main thread for every page-initiated new window. +/// Policy (v3 §5.3): scheme allow-list, then a user gesture within +/// `POPUP_GESTURE_WINDOW` (the popup blocker), then `Create` — the engine +/// navigates its own webview, so `window.opener`, `Referer` and `noopener` +/// semantics are exactly what the page asked for; the host only decides how +/// to present it, and for now every popup is adopted as a tab beside its +/// opener. +fn new_window_handler( + app: AppHandle, + owner: WebviewWindow, + opener_tab_id: String, +) -> impl Fn(String, NewWindowFeatures) -> NewWindowResponse + 'static { + move |url, features| { + let Some(registry) = app.try_state::() else { + return NewWindowResponse::Deny; + }; + let parsed = match Url::parse(&url) { + Ok(u) if policy::navigation_allowed(&u) => u, + _ => return deny(&app, &opener_tab_id, &url, &features, "blocked-scheme"), + }; + let has_gesture = registry + .recent_gestures(&opener_tab_id) + .iter() + .any(|g| g.received.elapsed() <= POPUP_GESTURE_WINDOW); + if !has_gesture { + return deny(&app, &opener_tab_id, &url, &features, "no-gesture"); + } + + #[cfg(target_os = "macos")] + { + let seq = POPUP_SEQ.fetch_add(1, Ordering::SeqCst) + 1; + let tab_id = format!("{opener_tab_id}-p{seq}"); + let label = super::tab_label(&tab_id); + let bounds = registry + .update(&opener_tab_id, |tab| tab.last_bounds) + .unwrap_or_default(); + let configuration = features.opener.target_configuration.clone(); + let webview = match build_child(&app, &owner, &tab_id, &label, bounds, true, Some(configuration)) { + Ok(webview) => webview, + Err(err) => { + tracing::warn!("[browser] popup webview creation failed: {err}"); + return deny(&app, &opener_tab_id, &url, &features, "create-failed"); + } + }; + // The platform object WebKit will load the request into. + let platform = objc2::rc::Retained::into_super( + tauri_runtime_wry::wry::WebViewExtMacOS::webview(&webview), + ); + SURFACES.with(|s| s.borrow_mut().insert(tab_id.clone(), webview)); + let handle = ChildHandle { + tab_id: tab_id.clone(), + label, + app: app.clone(), + }; + // Same controller as the opener in practice, so this is a no-op + // that still reports the channel kind; a fresh controller gets the + // full install. Either way it happens before WebKit loads anything. + let channel = match handle.install_channel() { + Ok(true) => ChannelKind::Degraded, // native once `hello` arrives + Ok(false) => ChannelKind::Legacy, + Err(err) => { + tracing::warn!("[browser] popup {tab_id}: page channel unavailable ({err})"); + ChannelKind::Degraded + } + }; + let state = BrowserTabState { + tab_id: tab_id.clone(), + owner_window: owner.label().to_string(), + surface: SurfaceKind::Child, + channel, + url: String::new(), + requested_url: parsed.to_string(), + title: String::new(), + favicon: None, + loading: true, + can_go_back: false, + can_go_forward: false, + origin: None, + zoom: 1.0, + error: None, + remote_host: None, + opener_tab_id: Some(opener_tab_id.clone()), + }; + if let Err(err) = registry.insert(BrowserTab::new( + state.clone(), + BrowserSurface::Child(handle), + bounds, + true, + )) { + tracing::warn!("[browser] popup registry insert failed: {err}"); + SURFACES.with(|s| s.borrow_mut().remove(&tab_id)); + return deny(&app, &opener_tab_id, &url, &features, "registry"); + } + events::emit_state(&app, &state); + events::emit_popup( + &app, + &BrowserPopupPayload { + presentation: PopupPresentation::Adopted, + opener_tab_id: opener_tab_id.clone(), + tab_id: Some(tab_id), + url: parsed.to_string(), + requested_size: features.size.map(|s| [s.width, s.height]), + reason: None, + }, + ); + NewWindowResponse::Create { webview: platform } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (&owner, parsed); + deny(&app, &opener_tab_id, &url, &features, "unsupported-platform") + } + } +} diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index d40809c5f0..7dc5eb7fdf 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -83,6 +83,9 @@ pub struct BrowserTabState { pub error: Option, /// Set when the tab's traffic egresses through a remote workspace host. pub remote_host: Option, + /// For a tab adopted from a page-initiated new-window request: the tab + /// whose page opened it (that page keeps a live `window.opener`). + pub opener_tab_id: Option, } /// Answer to `browser_capabilities`: what this build on this machine can do. @@ -109,6 +112,29 @@ pub enum SurfaceChoice { pub const STATE_EVENT: &str = "browser://state"; pub const CLOSED_EVENT: &str = "browser://closed"; +pub const POPUP_EVENT: &str = "browser://popup"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PopupPresentation { + /// The engine-created webview was adopted as a new tab next to its opener. + Adopted, + /// The request was refused (`reason` says why). + Denied, +} + +/// `browser://popup`: outcome of a page-initiated new-window request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserPopupPayload { + pub presentation: PopupPresentation, + pub opener_tab_id: String, + pub tab_id: Option, + pub url: String, + /// `window.open` size features, when the page asked for any. + pub requested_size: Option<[f64; 2]>, + pub reason: Option, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -139,6 +165,7 @@ mod tests { zoom: 1.0, error: None, remote_host: None, + opener_tab_id: None, }; let json = serde_json::to_value(&state).unwrap(); assert_eq!(json["tabId"], "t1"); diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index daf2fe5c37..36c9b6a505 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -152,7 +152,7 @@ pub fn open_tab_core( ) .map_err(|e| window_err("Failed to create browser webview", e))?, ), - _ => BrowserSurface::Window( + _ => BrowserSurface::Window(Box::new( crate::browser::surface_window::create( app, owner, @@ -162,7 +162,7 @@ pub fn open_tab_core( params.background, ) .map_err(|e| window_err("Failed to create browser window", e))?, - ), + )), }; let state = BrowserTabState { @@ -170,7 +170,7 @@ pub fn open_tab_core( owner_window: owner.label().to_string(), surface: surface.kind(), channel: ChannelKind::Degraded, - url: "about:blank".to_string(), + url: String::new(), requested_url: url.to_string(), title: String::new(), favicon: None, @@ -181,16 +181,40 @@ pub fn open_tab_core( zoom: 1.0, error: None, remote_host: None, + opener_tab_id: None, }; - if let Err(err) = registry.insert(BrowserTab { - state: state.clone(), - surface: surface.clone(), - last_bounds: params.bounds, - visible: !params.background, - }) { + if let Err(err) = registry.insert(BrowserTab::new( + state.clone(), + surface.clone(), + params.bounds, + !params.background, + )) { let _ = surface.close(); return Err(err); } + // The helper must be in place before the first real document loads; + // `about:blank` is still showing at this point. A failed install is not + // fatal: the tab works, only the page channel is missing. + let mut state = state; + if surface.is_embedded() { + match surface.install_channel() { + // Stays `degraded` until the helper's `hello` proves the round trip. + Ok(true) => {} + Ok(false) => { + if let Some(next) = + registry.update_state(¶ms.tab_id, |s| s.channel = ChannelKind::Legacy) + { + state = next; + } + } + Err(err) => { + tracing::warn!( + "[browser] tab {}: page channel unavailable ({err}); continuing degraded", + params.tab_id + ); + } + } + } if params.background && surface.is_embedded() { let _ = surface.hide(); } @@ -314,6 +338,28 @@ pub fn reload_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCo .map_err(|e| window_err("Failed to reload browser tab", e)) } +pub fn go_back_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { + surface_of(registry, tab_id)? + .go_back() + .map_err(|e| window_err("Failed to go back", e)) +} + +pub fn go_forward_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { + surface_of(registry, tab_id)? + .go_forward() + .map_err(|e| window_err("Failed to go forward", e)) +} + +pub fn stop_core(app: &AppHandle, registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { + surface_of(registry, tab_id)? + .stop() + .map_err(|e| window_err("Failed to stop loading", e))?; + if let Some(state) = registry.update_state(tab_id, |s| s.loading = false) { + events::emit_state(app, &state); + } + Ok(()) +} + pub fn state_core(registry: &BrowserRegistry, tab_id: &str) -> Result { registry .state(tab_id) @@ -408,6 +454,31 @@ pub async fn browser_reload( reload_core(®istry, &tab_id) } +#[tauri::command] +pub async fn browser_go_back( + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result<(), AppCommandError> { + go_back_core(®istry, &tab_id) +} + +#[tauri::command] +pub async fn browser_go_forward( + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result<(), AppCommandError> { + go_forward_core(®istry, &tab_id) +} + +#[tauri::command] +pub async fn browser_stop( + app: AppHandle, + registry: State<'_, BrowserRegistry>, + tab_id: String, +) -> Result<(), AppCommandError> { + stop_core(&app, ®istry, &tab_id) +} + #[tauri::command] pub async fn browser_get_state( registry: State<'_, BrowserRegistry>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d57f737a9..299ff03f7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1200,6 +1200,9 @@ mod tauri_app { browser_commands::browser_set_visible, browser_commands::browser_navigate, browser_commands::browser_reload, + browser_commands::browser_go_back, + browser_commands::browser_go_forward, + browser_commands::browser_stop, browser_commands::browser_get_state, browser_commands::browser_list_tabs, conversations::list_conversations, diff --git a/src/browser-injected/helper.js b/src/browser-injected/helper.js new file mode 100644 index 0000000000..0ac53f3c53 --- /dev/null +++ b/src/browser-injected/helper.js @@ -0,0 +1,237 @@ +// Built-in browser helper. Injected by the Rust host into the ISOLATED world +// of every browser tab (WKContentWorld "codeg" on macOS, a CDP isolated world +// on Windows, a WebKitGTK script world on Linux) at document start, for every +// frame. The page cannot see this script, its globals, or the native message +// channel it talks over — an isolated world shares the DOM but has its own +// JavaScript globals, so `JSON`, `addEventListener` and friends here are +// pristine even when the page overrides its own copies. +// +// It does exactly two things: +// 1. Reports navigation state the host cannot observe natively (SPA +// `pushState` / `replaceState` / hash changes, document title changes) +// as `nav-state` messages. +// 2. Records user gestures — click / auxclick / keydown in the capture +// phase — as `gesture` messages, including the anchor under the +// pointer, so the host can decide how to present a resulting new-window +// request (adopt as a tab vs. popup) and can turn a modifier-click on a +// plain anchor into a background tab from `on_navigation`. It never +// cancels, rewrites or re-dispatches anything: the engine navigates, the +// host only decides presentation. +// +// The host defines `__codegSend(string)` before this script runs. Messages +// are `{ kind, payload }` JSON strings; the host treats every field as +// untrusted input. +;(function codegBrowserHelper() { + "use strict" + if (typeof globalThis.__codegSend !== "function") return + if (globalThis.__codegHelperInstalled) return + globalThis.__codegHelperInstalled = true + + var send = globalThis.__codegSend + var stringify = JSON.stringify + var now = function () { + return Date.now() + } + var isTop = (function () { + try { + return window.top === window + } catch { + return false + } + })() + + function post(kind, payload) { + try { + send(stringify({ kind: kind, payload: payload, top: isTop })) + } catch { + /* the channel is best-effort; never throw into page event dispatch */ + } + } + + // ---- navigation state ------------------------------------------------- + var lastHref = "" + var lastTitle = "" + function reportNav(reason) { + var href = String(location.href) + var title = String(document.title || "") + if (href === lastHref && title === lastTitle) return + lastHref = href + lastTitle = title + post("nav-state", { href: href, title: title, reason: reason }) + } + // history.pushState / replaceState are the only SPA transitions with no + // native signal at all; wrapping them in this world affects only calls made + // from this world, so we observe the page's calls through the events they + // produce instead: none. Poll cheaply on user-visible ticks plus the events + // that do fire. + window.addEventListener( + "popstate", + function () { + reportNav("popstate") + }, + true + ) + window.addEventListener( + "hashchange", + function () { + reportNav("hashchange") + }, + true + ) + document.addEventListener( + "DOMContentLoaded", + function () { + reportNav("dom-ready") + }, + true + ) + window.addEventListener( + "load", + function () { + reportNav("load") + }, + true + ) + if (isTop) { + var titleObserver = null + function observeTitle() { + if (titleObserver || !document.documentElement) return + try { + titleObserver = new MutationObserver(function () { + reportNav("mutation") + }) + // lives in <head>; observing the document element catches it + // being created, replaced or edited without walking the whole body. + titleObserver.observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }) + } catch { + titleObserver = null + } + } + observeTitle() + document.addEventListener("DOMContentLoaded", observeTitle, true) + // pushState has no event: a light poll on animation frames only while + // the document is visible costs nothing measurable and catches SPA + // route changes within a frame. + var rafHref = "" + function tick() { + if (document.visibilityState === "visible") { + var href = String(location.href) + if (href !== rafHref) { + rafHref = href + reportNav("poll") + } + } + setTimeout(function () { + requestAnimationFrame(tick) + }, 250) + } + requestAnimationFrame(tick) + } + + // ---- gestures ---------------------------------------------------------- + var gestureSeq = 0 + function primaryModifier(event) { + // ⌘ on macOS, Ctrl elsewhere; the host knows the platform, so send both. + return { + meta: !!event.metaKey, + ctrl: !!event.ctrlKey, + shift: !!event.shiftKey, + alt: !!event.altKey, + } + } + function anchorFrom(event) { + var path + try { + path = + typeof event.composedPath === "function" ? event.composedPath() : [] + } catch { + path = [] + } + for (var i = 0; i < path.length; i++) { + var node = path[i] + if (!node || node.nodeType !== 1) continue + var tag = String(node.tagName || "").toLowerCase() + if (tag !== "a" && tag !== "area") continue + if (!node.hasAttribute || !node.hasAttribute("href")) continue + var href + try { + href = String(node.href || "") + } catch { + href = "" + } + var rel = String(node.getAttribute("rel") || "").toLowerCase() + return { + href: href, + target: String(node.getAttribute("target") || ""), + download: node.hasAttribute("download"), + relNoOpener: /(^|\s)noopener(\s|$)/.test(rel), + relNoReferrer: /(^|\s)noreferrer(\s|$)/.test(rel), + } + } + return null + } + // Synthetic events are ignored: a page can dispatch a fake click, but the + // host must only ever treat real input as a gesture. The isolated-world + // flag below is the one exception, set by the host's own test harness + // through a world-scoped eval (page scripts cannot reach this global). + function trusted(event) { + return !!event.isTrusted || globalThis.__codegAcceptUntrusted === true + } + function recordGesture(type, event, extra) { + gestureSeq += 1 + var payload = { + id: gestureSeq, + type: type, + ts: now(), + button: typeof event.button === "number" ? event.button : -1, + modifiers: primaryModifier(event), + anchor: anchorFrom(event), + isTrusted: !!event.isTrusted, + } + if (extra) { + for (var k in extra) payload[k] = extra[k] + } + post("gesture", payload) + return payload + } + window.addEventListener( + "click", + function (event) { + if (!trusted(event)) return + recordGesture("click", event) + }, + true + ) + window.addEventListener( + "keydown", + function (event) { + if (!trusted(event)) return + if (event.key !== "Enter" && event.key !== " ") return + recordGesture("keydown", event, { key: event.key }) + }, + true + ) + window.addEventListener( + "auxclick", + function (event) { + if (!trusted(event)) return + // Recorded only. Both engines already treat a middle-button auxclick + // on an anchor as "open in a new tab" (WebKit's HTMLAnchorElement counts + // it as a link click, WebView2 raises NewWindowRequested), so the + // request reaches the host's new-window handler on its own; opening it + // from here as well produced two tabs per middle-click. + recordGesture("auxclick", event) + }, + true + ) + + post("hello", { + href: String(location.href), + readyState: String(document.readyState), + }) + reportNav("start") +})() From 1de808b371bcf014a1dfa47b2364b20372abc58c Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 16:05:19 +0800 Subject: [PATCH 05/79] feat(browser): browser tab records, live-state store and event bridge file-tab-id learns a `browser:<backend id>` kind, and the workspace tab type becomes a discriminated union: file-like tabs keep their shape, browser tabs carry only a seed (initial URL, opener) and never a path. Live per-tab state (url, title, loading, history flags) lives in a separate useSyncExternalStore-backed store fed by browser://state, so page activity never churns the fileTabs slice. openBrowserTab opens one tab per URL (fragment ignored) and re-activates an existing one; adoptBrowserTab registers a popup the backend adopted, inserted right after its opener. Closing a browser tab releases its native surface. BrowserEventsBridge, mounted once in the workspace layout, subscribes to browser://state, browser://popup and browser://closed only when the backend reports a browser is available. Also adds the TypeScript mirror of the Rust wire types and the browser_* command wrappers. --- src/app/workspace/layout.tsx | 2 + .../browser/browser-events-bridge.test.tsx | 173 ++++++++++++++++++ .../browser/browser-events-bridge.tsx | 84 +++++++++ src/contexts/workspace-context.test.tsx | 147 +++++++++++++++ src/contexts/workspace-context.tsx | 150 ++++++++++++++- src/lib/browser/browser-api.ts | 121 ++++++++++++ src/lib/browser/browser-tab-store.test.ts | 102 +++++++++++ src/lib/browser/browser-tab-store.ts | 112 ++++++++++++ src/lib/browser/types.test.ts | 54 ++++++ src/lib/browser/types.ts | 92 ++++++++++ src/lib/file-tab-id.test.ts | 18 ++ src/lib/file-tab-id.ts | 17 ++ 12 files changed, 1068 insertions(+), 4 deletions(-) create mode 100644 src/components/browser/browser-events-bridge.test.tsx create mode 100644 src/components/browser/browser-events-bridge.tsx create mode 100644 src/lib/browser/browser-api.ts create mode 100644 src/lib/browser/browser-tab-store.test.ts create mode 100644 src/lib/browser/browser-tab-store.ts create mode 100644 src/lib/browser/types.test.ts create mode 100644 src/lib/browser/types.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index cef8021bb8..48c98700e4 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -1,5 +1,6 @@ "use client" +import { BrowserEventsBridge } from "@/components/browser/browser-events-bridge" import { Suspense, useMemo, @@ -1279,6 +1280,7 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { <TabProvider> <WorkspaceDocumentTitle /> <TabKeysSync /> + <BrowserEventsBridge /> <HeavyPluginsWarmup /> <DeepLinkBootstrap /> <PetFocusBridge /> diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx new file mode 100644 index 0000000000..35b1801342 --- /dev/null +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -0,0 +1,173 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { BrowserCapabilities } from "@/lib/browser/types" + +type Handler = (payload: unknown) => void + +const mocks = vi.hoisted(() => { + const handlers = new Map<string, Handler>() + const unsubscribed: string[] = [] + return { + handlers, + unsubscribed, + capabilities: vi.fn( + (): Promise<BrowserCapabilities> => + Promise.resolve({ + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + }) + ), + subscribe: vi.fn((event: string, handler: Handler) => { + handlers.set(event, handler) + return Promise.resolve(() => { + unsubscribed.push(event) + handlers.delete(event) + }) + }), + adoptBrowserTab: vi.fn(() => "browser:opener-p1"), + closeFileTab: vi.fn(), + } +}) + +vi.mock("@/lib/browser/browser-api", () => ({ + browserCapabilities: mocks.capabilities, + browserClose: vi.fn(() => Promise.resolve()), +})) +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ subscribe: mocks.subscribe }), + isDesktop: () => true, +})) +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceActions: () => ({ + adoptBrowserTab: mocks.adoptBrowserTab, + closeFileTab: mocks.closeFileTab, + }), +})) + +import { + getBrowserTabState, + resetBrowserTabStoreForTests, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { BrowserEventsBridge } from "./browser-events-bridge" + +async function flush() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe("BrowserEventsBridge", () => { + beforeEach(() => { + mocks.handlers.clear() + mocks.unsubscribed.length = 0 + mocks.subscribe.mockClear() + mocks.adoptBrowserTab.mockClear() + mocks.closeFileTab.mockClear() + resetBrowserTabStoreForTests() + }) + afterEach(() => resetBrowserTabStoreForTests()) + + it("subscribes to the three streams once the capabilities say a browser exists", async () => { + const { unmount } = render(<BrowserEventsBridge />) + await flush() + expect([...mocks.handlers.keys()].sort()).toEqual([ + "browser://closed", + "browser://popup", + "browser://state", + ]) + + mocks.handlers.get("browser://state")!({ + tabId: "abc", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/", + requestedUrl: "https://example.com/", + title: "Example", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + }) + expect(getBrowserTabState("browser:abc")?.title).toBe("Example") + + mocks.handlers.get("browser://popup")!({ + presentation: "adopted", + openerTabId: "abc", + tabId: "abc-p1", + url: "https://example.com/popup", + requestedSize: null, + reason: null, + }) + expect(mocks.adoptBrowserTab).toHaveBeenCalledWith({ + backendTabId: "abc-p1", + url: "https://example.com/popup", + openerBackendTabId: "abc", + }) + mocks.handlers.get("browser://popup")!({ + presentation: "denied", + openerTabId: "abc", + tabId: null, + url: "https://example.com/blocked", + requestedSize: null, + reason: "no-gesture", + }) + expect(mocks.adoptBrowserTab).toHaveBeenCalledTimes(1) + + setBrowserTabState({ + tabId: "abc-p1", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "", + requestedUrl: "https://example.com/popup", + title: "", + favicon: null, + loading: true, + canGoBack: false, + canGoForward: false, + origin: null, + zoom: 1, + error: null, + remoteHost: null, + openerTabId: "abc", + }) + mocks.handlers.get("browser://closed")!({ + tabId: "abc-p1", + ownerWindow: "main", + }) + expect(getBrowserTabState("browser:abc-p1")).toBeNull() + expect(mocks.closeFileTab).toHaveBeenCalledWith("browser:abc-p1") + + unmount() + expect(mocks.unsubscribed.sort()).toEqual([ + "browser://closed", + "browser://popup", + "browser://state", + ]) + }) + + it("stays silent when no built-in browser is available", async () => { + mocks.capabilities.mockResolvedValueOnce({ + available: false, + surface: null, + platform: "web", + channel: "degraded", + reasons: ["web"], + }) + render(<BrowserEventsBridge />) + await flush() + expect(mocks.subscribe).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx new file mode 100644 index 0000000000..5c759ab491 --- /dev/null +++ b/src/components/browser/browser-events-bridge.tsx @@ -0,0 +1,84 @@ +"use client" + +import { useEffect } from "react" + +import { useWorkspaceActions } from "@/contexts/workspace-context" +import { browserCapabilities } from "@/lib/browser/browser-api" +import { + browserWorkspaceTabId, + removeBrowserTabState, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { + BROWSER_CLOSED_EVENT, + BROWSER_POPUP_EVENT, + BROWSER_STATE_EVENT, + type BrowserClosedPayload, + type BrowserPopupPayload, + type BrowserTabState, +} from "@/lib/browser/types" +import { getTransport } from "@/lib/transport" + +/** + * The one subscriber to the backend's `browser://*` streams. Mounted once + * inside the workspace providers (it needs the workspace actions to add and + * remove tab records); renders nothing. + * + * - `browser://state` → the tab store (toolbar, status layer, tab title) + * - `browser://popup` → an adopted popup becomes a tab next to its opener + * - `browser://closed` → a surface the backend tore down (owned window closed + * by the user, owner window gone) drops its tab record + * + * Only subscribes where a built-in browser exists; in web mode there is + * nothing to hear. + */ +export function BrowserEventsBridge() { + const { adoptBrowserTab, closeFileTab } = useWorkspaceActions() + + useEffect(() => { + let cancelled = false + const unsubscribers: Array<() => void> = [] + + void (async () => { + const capabilities = await browserCapabilities() + if (cancelled || !capabilities.available) return + const transport = getTransport() + const subs = await Promise.all([ + transport.subscribe<BrowserTabState>(BROWSER_STATE_EVENT, (state) => { + setBrowserTabState(state) + }), + transport.subscribe<BrowserPopupPayload>( + BROWSER_POPUP_EVENT, + (popup) => { + if (popup.presentation !== "adopted" || !popup.tabId) return + adoptBrowserTab({ + backendTabId: popup.tabId, + url: popup.url, + openerBackendTabId: popup.openerTabId, + }) + } + ), + transport.subscribe<BrowserClosedPayload>( + BROWSER_CLOSED_EVENT, + (closed) => { + const tabId = browserWorkspaceTabId(closed.tabId) + removeBrowserTabState(tabId) + closeFileTab(tabId) + } + ), + ]) + if (cancelled) { + for (const unsubscribe of subs) unsubscribe() + return + } + unsubscribers.push(...subs) + })() + + return () => { + cancelled = true + for (const unsubscribe of unsubscribers) unsubscribe() + } + }, [adoptBrowserTab, closeFileTab]) + + return null +} diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx index c0a588a40b..f2888a1ed7 100644 --- a/src/contexts/workspace-context.test.tsx +++ b/src/contexts/workspace-context.test.tsx @@ -3252,3 +3252,150 @@ describe("unified absolute-path file tabs (outside-workspace opens)", () => { ) }) }) + +describe("browser tabs", () => { + function BrowserProbe() { + const { openBrowserTab, adoptBrowserTab, closeFileTab } = + useWorkspaceActions() + const { fileTabs, activeFileTabId } = useWorkspaceFileTabs() + const { activePane } = useWorkspaceView() + return ( + <div> + <button onClick={() => openBrowserTab("https://example.com/docs#top")}> + open + </button> + <button + onClick={() => + openBrowserTab("https://example.com/docs#other", { + activate: true, + }) + } + > + open-same + </button> + <button + onClick={() => + openBrowserTab("http://localhost:3000/", { activate: false }) + } + > + open-bg + </button> + <button onClick={() => openBrowserTab("not a url")}>open-bad</button> + <button + onClick={() => { + const opener = fileTabs.find((t) => t.kind === "browser") + if (!opener) return + const parts = opener.id.slice("browser:".length) + adoptBrowserTab({ + backendTabId: `${parts}-p1`, + url: "https://example.com/popup", + openerBackendTabId: parts, + }) + }} + > + adopt + </button> + <button + onClick={() => { + if (activeFileTabId) closeFileTab(activeFileTabId) + }} + > + close-active + </button> + <pre data-testid="tabs"> + {JSON.stringify( + fileTabs.map((t) => ({ + id: t.id, + kind: t.kind, + title: t.title, + path: t.path, + opener: t.kind === "browser" ? t.browser.openerTabId : undefined, + url: t.kind === "browser" ? t.browser.initialUrl : undefined, + })) + )} + </pre> + <span data-testid="active">{activeFileTabId ?? ""}</span> + <span data-testid="pane">{activePane}</span> + </div> + ) + } + + function readTabs(): Array<{ + id: string + kind: string + title: string + path: string | null + opener?: string | null + url?: string + }> { + return JSON.parse(screen.getByTestId("tabs").textContent ?? "[]") + } + + it("opens one browser tab per URL, activates it, and de-dupes by URL without fragment", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + let tabs = readTabs() + expect(tabs).toHaveLength(1) + expect(tabs[0].kind).toBe("browser") + expect(tabs[0].id.startsWith("browser:")).toBe(true) + expect(tabs[0].path).toBeNull() + expect(tabs[0].title).toBe("example.com") + expect(screen.getByTestId("active").textContent).toBe(tabs[0].id) + expect(screen.getByTestId("pane").textContent).toBe("files") + + act(() => screen.getByText("open-same").click()) + tabs = readTabs() + expect(tabs).toHaveLength(1) + + act(() => screen.getByText("open-bad").click()) + expect(readTabs()).toHaveLength(1) + + act(() => screen.getByText("open-bg").click()) + tabs = readTabs() + expect(tabs).toHaveLength(2) + // Background open must not steal the active tab. + expect(screen.getByTestId("active").textContent).toBe(tabs[0].id) + }) + + it("inserts an adopted popup right after its opener and activates it", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + act(() => screen.getByText("open-bg").click()) + act(() => screen.getByText("adopt").click()) + const tabs = readTabs() + expect(tabs.map((t) => t.url)).toEqual([ + "https://example.com/docs#top", + "https://example.com/popup", + "http://localhost:3000/", + ]) + expect(tabs[1].id).toBe(`${tabs[0].id}-p1`) + expect(tabs[1].opener).toBe(tabs[0].id) + expect(screen.getByTestId("active").textContent).toBe(tabs[1].id) + }) + + it("closes a browser tab without a dirty prompt and moves activation to a neighbour", () => { + const confirmSpy = vi.spyOn(window, "confirm") + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + act(() => screen.getByText("open-bg").click()) + act(() => screen.getByText("close-active").click()) + expect(confirmSpy).not.toHaveBeenCalled() + const tabs = readTabs() + expect(tabs).toHaveLength(1) + expect(tabs[0].url).toBe("http://localhost:3000/") + expect(screen.getByTestId("active").textContent).toBe(tabs[0].id) + confirmSpy.mockRestore() + }) +}) diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index f4aa69a814..608b6ff144 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -62,17 +62,29 @@ import { type WorkspaceExternalConflict, } from "@/hooks/use-open-file-tabs-watch" import { useOfficeAutoPreview } from "@/lib/office-preview-prefs" +import { releaseBrowserTab } from "@/lib/browser/browser-tab-store" +import { hostnameOf, normalizeUrlForDedupe } from "@/lib/browser/browser-url" export type WorkspaceMode = "conversation" | "fusion" export type WorkspacePane = "conversation" | "files" -type FileWorkspaceTabKind = "file" | "diff" | "rich-diff" +type FileLikeTabKind = "file" | "diff" | "rich-diff" +type FileWorkspaceTabKind = FileLikeTabKind | "browser" type FileSaveState = "idle" | "saving" | "error" type LineEnding = "lf" | "crlf" | "mixed" | "none" -export interface FileWorkspaceTab { +/** Seed of a built-in browser tab. Live state (url, title, loading, history) + * lives in `lib/browser/browser-tab-store`, keyed by the tab id, so page + * activity never churns the `fileTabs` slice. */ +export interface BrowserTabSeed { + /** The URL the tab was opened with; the surface navigates to it once. */ + initialUrl: string + /** Set on a tab adopted from another tab's `window.open` (popup). */ + openerTabId: string | null +} + +interface FileWorkspaceTabBase { id: string - kind: FileWorkspaceTabKind // Repo context for git-scoped diff tabs (working/branch/commit/session // diffs are repository operations and need the repo root). Plain file // tabs are folder-free: folderId is ALWAYS null and `path` holds the @@ -108,6 +120,25 @@ export interface FileWorkspaceTab { stale?: boolean } +/** A file, a unified diff, or a rich (side-by-side) diff. */ +export interface FileLikeWorkspaceTab extends FileWorkspaceTabBase { + kind: FileLikeTabKind + browser?: never +} + +/** A built-in browser tab: no path, no editable content. */ +export interface BrowserWorkspaceTab extends FileWorkspaceTabBase { + kind: "browser" + path: null + language: "browser" + browser: BrowserTabSeed +} + +// A discriminated union rather than optional fields on one shape: a `file` +// tab can never carry browser state and a `browser` tab can never be dirty, +// and the compiler should say so at every switch on `kind`. +export type FileWorkspaceTab = FileLikeWorkspaceTab | BrowserWorkspaceTab + // The provider value is split across three contexts so high-frequency // fileTabs churn (per-keystroke content updates, watcher-driven reloads) // only re-renders components that actually read tab data. Action-only @@ -198,6 +229,22 @@ interface WorkspaceActionsValue { reloadActiveFile: () => Promise<void> toggleFileTabPreview: (tabId: string) => void toggleFilesMaximized: () => void + // Open (or re-activate) a built-in browser tab for an http(s) URL. One tab + // per URL (fragment ignored): a second open activates the existing tab. + // Returns the tab id, or null when the URL does not parse. The native + // surface is created by the tab's view when it mounts, not here. + openBrowserTab: ( + url: string, + options?: { folderId?: number; activate?: boolean } + ) => string | null + // Register a tab for a webview the BACKEND already created — a popup the + // page opened that the host adopted. `backendTabId` is the backend's id + // (`<opener>-p<n>`); the record is inserted right after its opener. + adoptBrowserTab: (params: { + backendTabId: string + url: string + openerBackendTabId: string + }) => string } interface WorkspaceViewValue { @@ -313,7 +360,7 @@ const IMAGE_MIME: Record<string, string> = { function loadingTab( id: string, folderId: number | null, - kind: FileWorkspaceTabKind, + kind: FileLikeTabKind, title: string, description: string | null, path: string | null, @@ -622,6 +669,93 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { [activateFilePane] ) + const browserTabRecord = useCallback( + ( + backendTabId: string, + url: string, + folderId: number | null, + openerTabId: string | null + ): BrowserWorkspaceTab => ({ + id: buildFileTabId({ kind: "browser", id: backendTabId }), + kind: "browser", + folderId, + title: hostnameOf(url) ?? url, + description: null, + path: null, + language: "browser", + content: "", + loading: true, + readonly: true, + browser: { initialUrl: url, openerTabId }, + }), + [] + ) + + const openBrowserTab = useCallback( + (url: string, options?: { folderId?: number; activate?: boolean }) => { + const normalized = normalizeUrlForDedupe(url) + if (!normalized) return null + const existing = fileTabsRef.current.find( + (tab) => + tab.kind === "browser" && + normalizeUrlForDedupe(tab.browser.initialUrl) === normalized + ) + if (existing) { + if (options?.activate !== false) activateTab(existing.id) + return existing.id + } + const record = browserTabRecord( + crypto.randomUUID(), + url, + options?.folderId ?? activeFolderRef.current?.id ?? null, + null + ) + if (options?.activate === false) { + setFileTabs((prev) => + prev.some((tab) => tab.id === record.id) ? prev : [...prev, record] + ) + } else { + seedLoadingTab(record) + } + return record.id + }, + [activateTab, browserTabRecord, seedLoadingTab] + ) + + const adoptBrowserTab = useCallback( + (params: { + backendTabId: string + url: string + openerBackendTabId: string + }) => { + const openerId = buildFileTabId({ + kind: "browser", + id: params.openerBackendTabId, + }) + const opener = fileTabsRef.current.find((tab) => tab.id === openerId) + const record = browserTabRecord( + params.backendTabId, + params.url, + opener?.folderId ?? activeFolderRef.current?.id ?? null, + openerId + ) + setFileTabs((prev) => { + if (prev.some((tab) => tab.id === record.id)) return prev + const idx = prev.findIndex((tab) => tab.id === openerId) + if (idx < 0) return [...prev, record] + const next = [...prev] + next.splice(idx + 1, 0, record) + return next + }) + // A popup is what the user just clicked for: show it, like a browser + // would, and the opener stays one tab to the left. + setActiveFileTabId(record.id) + activateFilePane() + return record.id + }, + [activateFilePane, browserTabRecord] + ) + // Mark an existing tab as refreshing. Preserves content / originalContent / // modifiedContent / gitBaseContent / savedContent / etag / mtimeMs / // isDirty / readonly / lineEnding. Clears any prior error state. @@ -2293,6 +2427,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { // more than once (StrictMode, or a discarded render replayed). const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) + // Idempotent on the backend, so safe under a replayed updater. + if (tab.kind === "browser") releaseBrowserTab(tab.id) const next = prev.filter((candidate) => candidate.id !== tabId) @@ -2345,6 +2481,7 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { // safe inside an updater React may invoke more than once. const closed = snapshotFileTab(closing) if (closed) pushClosedTab(closed) + if (closing.kind === "browser") releaseBrowserTab(closing.id) inFlightLoadsRef.current.delete(closing.id) } @@ -2366,6 +2503,7 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { for (const tab of prev) { const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) + if (tab.kind === "browser") releaseBrowserTab(tab.id) } inFlightLoadsRef.current.clear() @@ -2537,6 +2675,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { reloadActiveFile, toggleFileTabPreview, toggleFilesMaximized, + openBrowserTab, + adoptBrowserTab, }), [ setActivePane, @@ -2565,6 +2705,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { reloadActiveFile, toggleFileTabPreview, toggleFilesMaximized, + openBrowserTab, + adoptBrowserTab, ] ) diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts new file mode 100644 index 0000000000..daca06d857 --- /dev/null +++ b/src/lib/browser/browser-api.ts @@ -0,0 +1,121 @@ +// Thin transport wrappers over the `browser_*` commands. Desktop only: the +// commands exist in the Tauri runtime alone, and `browserCapabilities()` +// answers `{ available: false }` everywhere else so callers can branch on one +// value instead of on the runtime. + +import { getTransport, isDesktop } from "@/lib/transport" + +import type { + Bounds, + BrowserCapabilities, + BrowserTabState, + SurfaceChoice, +} from "./types" + +const UNAVAILABLE: BrowserCapabilities = { + available: false, + surface: null, + platform: "web", + channel: "degraded", + reasons: ["built-in browser needs the desktop runtime"], +} + +let capabilitiesPromise: Promise<BrowserCapabilities> | null = null + +/** Cached for the session: the answer cannot change while the app runs. */ +export function browserCapabilities(): Promise<BrowserCapabilities> { + if (!isDesktop()) return Promise.resolve(UNAVAILABLE) + if (!capabilitiesPromise) { + capabilitiesPromise = getTransport() + .call<BrowserCapabilities>("browser_capabilities", {}) + .catch((error: unknown) => { + capabilitiesPromise = null + return { + ...UNAVAILABLE, + reasons: [`browser_capabilities failed: ${String(error)}`], + } + }) + } + return capabilitiesPromise +} + +/** Tests only. */ +export function resetBrowserCapabilitiesCacheForTests(): void { + capabilitiesPromise = null +} + +export interface OpenBrowserTabParams { + tabId: string + url: string + bounds: Bounds + background?: boolean + surface?: SurfaceChoice + folderId?: number | null +} + +export function browserOpenTab( + params: OpenBrowserTabParams +): Promise<BrowserTabState> { + return getTransport().call<BrowserTabState>("browser_open_tab", { + tabId: params.tabId, + url: params.url, + bounds: params.bounds, + background: params.background ?? false, + surface: params.surface ?? "auto", + folderId: params.folderId ?? null, + }) +} + +export function browserClose(tabId: string): Promise<void> { + return getTransport().call<void>("browser_close", { tabId }) +} + +export function browserSetBounds(tabId: string, bounds: Bounds): Promise<void> { + return getTransport().call<void>("browser_set_bounds", { tabId, bounds }) +} + +export function browserSetVisible( + tabId: string, + visible: boolean, + handoffFocus = false +): Promise<void> { + return getTransport().call<void>("browser_set_visible", { + tabId, + visible, + handoffFocus, + }) +} + +export function browserNavigate( + tabId: string, + url: string +): Promise<BrowserTabState> { + return getTransport().call<BrowserTabState>("browser_navigate", { + tabId, + url, + }) +} + +export function browserReload(tabId: string): Promise<void> { + return getTransport().call<void>("browser_reload", { tabId }) +} + +export function browserStop(tabId: string): Promise<void> { + return getTransport().call<void>("browser_stop", { tabId }) +} + +export function browserGoBack(tabId: string): Promise<void> { + return getTransport().call<void>("browser_go_back", { tabId }) +} + +export function browserGoForward(tabId: string): Promise<void> { + return getTransport().call<void>("browser_go_forward", { tabId }) +} + +export function browserGetState(tabId: string): Promise<BrowserTabState> { + return getTransport().call<BrowserTabState>("browser_get_state", { tabId }) +} + +export function browserListTabs(): Promise<BrowserTabState[]> { + return getTransport().call<BrowserTabState[]>("browser_list_tabs", {}) +} diff --git a/src/lib/browser/browser-tab-store.test.ts b/src/lib/browser/browser-tab-store.test.ts new file mode 100644 index 0000000000..86e7e45de1 --- /dev/null +++ b/src/lib/browser/browser-tab-store.test.ts @@ -0,0 +1,102 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const api = vi.hoisted(() => ({ + browserClose: vi.fn(() => Promise.resolve()), + isDesktop: vi.fn(() => true), +})) +vi.mock("./browser-api", () => ({ browserClose: api.browserClose })) +vi.mock("@/lib/transport", () => ({ isDesktop: api.isDesktop })) + +import { + browserWorkspaceTabId, + getBrowserTabState, + releaseBrowserTab, + removeBrowserTabState, + resetBrowserTabStoreForTests, + setBrowserTabState, + subscribeBrowserTabs, + useBrowserTabState, +} from "./browser-tab-store" +import type { BrowserTabState } from "./types" + +function state(over: Partial<BrowserTabState> = {}): BrowserTabState { + return { + tabId: "abc", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/", + requestedUrl: "https://example.com/", + title: "Example", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + ...over, + } +} + +describe("browser tab store", () => { + beforeEach(() => { + resetBrowserTabStoreForTests() + api.browserClose.mockClear() + api.isDesktop.mockReturnValue(true) + }) + afterEach(() => resetBrowserTabStoreForTests()) + + it("keys state by the workspace tab id derived from the backend id", () => { + setBrowserTabState(state()) + expect(browserWorkspaceTabId("abc")).toBe("browser:abc") + expect(getBrowserTabState("browser:abc")?.title).toBe("Example") + expect(getBrowserTabState("browser:zzz")).toBeNull() + }) + + it("notifies subscribers only when something actually changed", () => { + const listener = vi.fn() + subscribeBrowserTabs(listener) + setBrowserTabState(state()) + setBrowserTabState(state()) + expect(listener).toHaveBeenCalledTimes(1) + setBrowserTabState(state({ loading: true })) + expect(listener).toHaveBeenCalledTimes(2) + const first = getBrowserTabState("browser:abc") + setBrowserTabState(state({ loading: true })) + expect(getBrowserTabState("browser:abc")).toBe(first) + removeBrowserTabState("browser:abc") + expect(listener).toHaveBeenCalledTimes(3) + removeBrowserTabState("browser:abc") + expect(listener).toHaveBeenCalledTimes(3) + }) + + it("useBrowserTabState re-renders for its own tab only", () => { + const { result, rerender } = renderHook( + ({ id }: { id: string | null }) => useBrowserTabState(id), + { initialProps: { id: "browser:abc" as string | null } } + ) + expect(result.current).toBeNull() + act(() => setBrowserTabState(state())) + expect(result.current?.url).toBe("https://example.com/") + act(() => setBrowserTabState(state({ tabId: "other", url: "https://o/" }))) + expect(result.current?.url).toBe("https://example.com/") + rerender({ id: null }) + expect(result.current).toBeNull() + }) + + it("releaseBrowserTab forgets the state and closes the backend surface on desktop", () => { + setBrowserTabState(state()) + releaseBrowserTab("browser:abc") + expect(getBrowserTabState("browser:abc")).toBeNull() + expect(api.browserClose).toHaveBeenCalledWith("abc") + api.isDesktop.mockReturnValue(false) + releaseBrowserTab("browser:abc") + expect(api.browserClose).toHaveBeenCalledTimes(1) + releaseBrowserTab("file:%2Fx") + expect(api.browserClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts new file mode 100644 index 0000000000..19564febf6 --- /dev/null +++ b/src/lib/browser/browser-tab-store.ts @@ -0,0 +1,112 @@ +// Live state of built-in browser tabs, keyed by the WORKSPACE tab id +// (`browser:<backend id>`). Fed by `BrowserEventsBridge` from the +// `browser://state` stream and by the surface host after `browser_open_tab`; +// read through `useSyncExternalStore` so only components looking at one tab +// re-render when that tab changes. Kept out of the workspace tab record on +// purpose: loading progress and URL changes are frequent and must not churn +// the whole `fileTabs` slice. + +import { useSyncExternalStore } from "react" + +import { browserClose } from "./browser-api" +import type { BrowserTabState } from "./types" +import { buildFileTabId } from "@/lib/file-tab-id" +import { isDesktop } from "@/lib/transport" + +type Listener = () => void + +const states = new Map<string, BrowserTabState>() +const listeners = new Set<Listener>() + +function notify(): void { + for (const listener of [...listeners]) listener() +} + +/** Workspace tab id for a backend tab id. */ +export function browserWorkspaceTabId(backendTabId: string): string { + return buildFileTabId({ kind: "browser", id: backendTabId }) +} + +export function getBrowserTabState( + workspaceTabId: string +): BrowserTabState | null { + return states.get(workspaceTabId) ?? null +} + +/** Replace a tab's state (identity changes only when the payload does). */ +export function setBrowserTabState(state: BrowserTabState): void { + const key = browserWorkspaceTabId(state.tabId) + const previous = states.get(key) + if (previous && shallowEqualState(previous, state)) return + states.set(key, state) + notify() +} + +export function removeBrowserTabState(workspaceTabId: string): void { + if (states.delete(workspaceTabId)) notify() +} + +export function subscribeBrowserTabs(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getServerSnapshot(): null { + return null +} + +/** Live state for one tab, or null before its first `browser://state`. */ +export function useBrowserTabState( + workspaceTabId: string | null +): BrowserTabState | null { + return useSyncExternalStore( + subscribeBrowserTabs, + () => (workspaceTabId ? (states.get(workspaceTabId) ?? null) : null), + getServerSnapshot + ) +} + +/** + * Tear down a tab's native surface and forget its state. Idempotent on the + * backend side, so calling it for a tab that never got a surface is fine. + */ +export function releaseBrowserTab(workspaceTabId: string): void { + removeBrowserTabState(workspaceTabId) + const backendId = workspaceTabId.startsWith("browser:") + ? decodeURIComponent(workspaceTabId.slice("browser:".length)) + : null + if (backendId && isDesktop()) { + void browserClose(backendId).catch(() => { + /* already gone */ + }) + } +} + +export function resetBrowserTabStoreForTests(): void { + states.clear() + listeners.clear() +} + +function shallowEqualState(a: BrowserTabState, b: BrowserTabState): boolean { + const keys = Object.keys(a) as (keyof BrowserTabState)[] + if (keys.length !== Object.keys(b).length) return false + for (const key of keys) { + const x = a[key] + const y = b[key] + if (x === y) continue + // `error` is the only nested object; compare it structurally. + if ( + key === "error" && + x && + y && + typeof x === "object" && + typeof y === "object" + ) { + if (JSON.stringify(x) === JSON.stringify(y)) continue + } + return false + } + return true +} diff --git a/src/lib/browser/types.test.ts b/src/lib/browser/types.test.ts new file mode 100644 index 0000000000..27d812a0b2 --- /dev/null +++ b/src/lib/browser/types.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" + +import type { + BrowserCapabilities, + BrowserPopupPayload, + BrowserTabState, +} from "./types" + +// These literals are copied from what the Rust side serializes (see the +// `wire_names_are_camel_and_kebab` test in src-tauri/src/browser/types.rs and +// the P0 puppet output). If a field is renamed on one side, `satisfies` fails +// here and the Rust test fails there. +describe("browser wire types", () => { + it("matches the Rust serialization of BrowserTabState", () => { + const state = { + tabId: "t1", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/", + requestedUrl: "https://example.com/", + title: "Example Domain", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1.0, + error: null, + remoteHost: null, + openerTabId: null, + } satisfies BrowserTabState + expect(state.surface).toBe("child") + }) + + it("matches the Rust serialization of BrowserCapabilities and popups", () => { + const caps = { + available: true, + surface: "child", + platform: "macos", + channel: "degraded", + reasons: ["page channel not installed yet"], + } satisfies BrowserCapabilities + const popup = { + presentation: "adopted", + openerTabId: "t1", + tabId: "t1-p1", + url: "http://127.0.0.1:8765/popup.html", + requestedSize: [520, 640], + reason: null, + } satisfies BrowserPopupPayload + expect(caps.available && popup.presentation === "adopted").toBe(true) + }) +}) diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts new file mode 100644 index 0000000000..1d06299b1e --- /dev/null +++ b/src/lib/browser/types.ts @@ -0,0 +1,92 @@ +// Wire types of the built-in browser — the TypeScript mirror of +// `src-tauri/src/browser/types.rs`. Field names are camelCase and enum values +// kebab-case on both sides; change them together. + +export type SurfaceKind = "child" | "window" + +export type ChannelKind = "native" | "degraded" | "legacy" + +/** Logical pixels, the unit `getBoundingClientRect()` reports. */ +export interface Bounds { + x: number + y: number + width: number + height: number +} + +export type BrowserErrorKind = + | "dns" + | "tls" + | "blocked" + | "failed" + | "popup-denied" + +export interface BrowserErrorInfo { + kind: BrowserErrorKind + message: string + url: string | null +} + +/** Full per-tab state; every `browser://state` event carries one. */ +export interface BrowserTabState { + tabId: string + ownerWindow: string + surface: SurfaceKind + channel: ChannelKind + /** Last committed URL ("" until the first document commits). */ + url: string + /** URL the last navigation asked for. */ + requestedUrl: string + title: string + favicon: string | null + loading: boolean + canGoBack: boolean + canGoForward: boolean + origin: string | null + zoom: number + error: BrowserErrorInfo | null + remoteHost: string | null + /** Set on a tab adopted from a page-initiated new-window request. */ + openerTabId: string | null +} + +export interface BrowserCapabilities { + available: boolean + surface: SurfaceKind | null + platform: string + channel: ChannelKind + reasons: string[] +} + +export type SurfaceChoice = "auto" | "child" | "window" + +export interface BrowserClosedPayload { + tabId: string + ownerWindow: string +} + +export type PopupPresentation = "adopted" | "denied" + +export interface BrowserPopupPayload { + presentation: PopupPresentation + openerTabId: string + tabId: string | null + url: string + requestedSize: [number, number] | null + reason: string | null +} + +/** `browser://telemetry`: page-side data forwarded as-is; never trust it. */ +export interface BrowserTelemetryPayload { + tabId: string + kind: "gesture" + untrusted: true + mainFrame: boolean + top: boolean + payload: unknown +} + +export const BROWSER_STATE_EVENT = "browser://state" +export const BROWSER_CLOSED_EVENT = "browser://closed" +export const BROWSER_POPUP_EVENT = "browser://popup" +export const BROWSER_TELEMETRY_EVENT = "browser://telemetry" diff --git a/src/lib/file-tab-id.test.ts b/src/lib/file-tab-id.test.ts index 4488d39ccf..423b9ed022 100644 --- a/src/lib/file-tab-id.test.ts +++ b/src/lib/file-tab-id.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { + browserTabBackendId, buildFileTabId, parseFileTabId, type FileTabIdParts, @@ -100,3 +101,20 @@ describe("file-tab-id build↔parse round-trip", () => { expect(id.split(":")).toHaveLength(5) }) }) + +describe("browser tab ids", () => { + it("round-trips the backend tab id, including adopted popups", () => { + for (const id of ["b7e2c1d0-1a2b-4c3d-9e8f-0a1b2c3d4e5f", "abc-p1", "t1"]) { + const tabId = buildFileTabId({ kind: "browser", id }) + expect(tabId).toBe(`browser:${id}`) + expect(parseFileTabId(tabId)).toEqual({ kind: "browser", id }) + expect(browserTabBackendId(tabId)).toBe(id) + } + }) + + it("rejects malformed browser ids and non-browser tabs", () => { + expect(parseFileTabId("browser:")).toBeNull() + expect(parseFileTabId("browser:a:b")).toBeNull() + expect(browserTabBackendId("file:%2Frepo%2Fa.ts")).toBeNull() + }) +}) diff --git a/src/lib/file-tab-id.ts b/src/lib/file-tab-id.ts index 9728d0d58e..240476fc36 100644 --- a/src/lib/file-tab-id.ts +++ b/src/lib/file-tab-id.ts @@ -39,6 +39,10 @@ export type FileTabIdParts = } | { kind: "diff-session"; folderId: number; groupLabel: string; path: string } | { kind: "diff-external-conflict"; path: string } + // Built-in browser tab. `id` is the backend's tab id (a uuid, or + // `<opener-uuid>-p<n>` for a popup adopted from another tab); it never + // contains ":" so it needs no encoding, but goes through the encoder anyway. + | { kind: "browser"; id: string } export type FileTabIdKind = FileTabIdParts["kind"] @@ -87,9 +91,17 @@ export function buildFileTabId(parts: FileTabIdParts): string { return `diff:session:${parts.folderId}:${encodeToken(parts.groupLabel)}:${encodeToken(parts.path)}` case "diff-external-conflict": return `diff:external-conflict:${encodeToken(parts.path)}` + case "browser": + return `browser:${encodeToken(parts.id)}` } } +/** The backend tab id of a browser tab, or null for any other tab. */ +export function browserTabBackendId(tabId: string): string | null { + const parts = parseFileTabId(tabId) + return parts?.kind === "browser" ? parts.id : null +} + // Strict numeric-only folder segment: rejects "", "1x", "-1" so a malformed // or legacy id can never silently parse into the wrong folder. function parseFolderIdSegment(segment: string | undefined): number | null { @@ -106,6 +118,11 @@ export function parseFileTabId(id: string): FileTabIdParts | null { return { kind: "file", path: decodeToken(segments[1]) } } + if (head === "browser") { + if (segments.length !== 2 || segments[1] === "") return null + return { kind: "browser", id: decodeToken(segments[1]) } + } + if (head !== "diff") return null const variant = segments[1] From f5b719d8af6248b3f24188eca2380c7e357bf74d Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 16:20:35 +0800 Subject: [PATCH 06/79] feat(browser): tab view with native surface host, occlusion leases and viewer drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser tab now renders in the file pane: a toolbar (back / forward / reload-stop / address bar / copy / open in system browser, with a loading bar), notice bars for blocked popups and remote egress, and the BrowserSurfaceHost placeholder the native webview is fitted to. The host creates the surface once per tab record, keeps the webview's bounds equal to its rect (ResizeObserver + rAF), and hides it whenever the rect is not truly visible — the file column CSS-hidden in conversation mode, a full-page route covering the workspace, a DOM error page replacing the page — handing focus back to the main webview first. Native views paint above the DOM, so overlays take occlusion leases: Dialog, AlertDialog, Drawer, DropdownMenu and ContextMenu content hold one while mounted (the browser viewer drawer opts out because it hosts a surface itself), and a MutationObserver fallback catches lease-less open dialogs and menus. Any lease hides every surface. The transcript's side panel gains a browser viewer for links opened under a full-page route, backed by the same workspace tab record. Tab strip items show the page's live title and a globe icon. The backend can ask a window to open a URL over browser://open-request (agent tools and deep links later; the dev puppet now). Strings live under the new Browser i18n namespace in all ten locales. --- src-tauri/src/browser/events.rs | 8 +- src-tauri/src/browser/smoke.rs | 30 +++ src-tauri/src/browser/types.rs | 15 ++ src/app/globals.css | 10 + .../browser/browser-events-bridge.test.tsx | 23 ++ .../browser/browser-events-bridge.tsx | 30 ++- .../browser/browser-status-layer.tsx | 155 +++++++++++++ .../browser/browser-surface-host.test.tsx | 159 +++++++++++++ .../browser/browser-surface-host.tsx | 209 ++++++++++++++++++ src/components/browser/browser-tab-view.tsx | 57 +++++ .../browser/browser-toolbar.test.tsx | 35 +++ src/components/browser/browser-toolbar.tsx | 206 +++++++++++++++++ .../browser/browser-viewer-drawer.tsx | 116 ++++++++++ src/components/files/file-workspace-panel.tsx | 9 + .../files/file-workspace-tab-bar.tsx | 26 ++- .../message/session-viewer-host-context.ts | 9 + .../message/session-viewer-host.tsx | 9 + src/components/ui/alert-dialog.tsx | 3 + src/components/ui/context-menu.tsx | 3 + src/components/ui/dialog.tsx | 3 + src/components/ui/drawer.tsx | 7 + src/components/ui/dropdown-menu.tsx | 3 + src/i18n/messages/ar.json | 38 ++++ src/i18n/messages/de.json | 38 ++++ src/i18n/messages/en.json | 38 ++++ src/i18n/messages/es.json | 38 ++++ src/i18n/messages/fr.json | 38 ++++ src/i18n/messages/ja.json | 38 ++++ src/i18n/messages/ko.json | 38 ++++ src/i18n/messages/pt.json | 38 ++++ src/i18n/messages/zh-CN.json | 38 ++++ src/i18n/messages/zh-TW.json | 38 ++++ src/lib/browser/browser-tab-store.ts | 32 ++- .../browser/native-surface-occlusion.test.ts | 75 +++++++ src/lib/browser/native-surface-occlusion.ts | 146 ++++++++++++ src/lib/browser/types.ts | 10 + src/lib/browser/window-label.ts | 16 ++ 37 files changed, 1774 insertions(+), 10 deletions(-) create mode 100644 src/components/browser/browser-status-layer.tsx create mode 100644 src/components/browser/browser-surface-host.test.tsx create mode 100644 src/components/browser/browser-surface-host.tsx create mode 100644 src/components/browser/browser-tab-view.tsx create mode 100644 src/components/browser/browser-toolbar.test.tsx create mode 100644 src/components/browser/browser-toolbar.tsx create mode 100644 src/components/browser/browser-viewer-drawer.tsx create mode 100644 src/lib/browser/native-surface-occlusion.test.ts create mode 100644 src/lib/browser/native-surface-occlusion.ts create mode 100644 src/lib/browser/window-label.ts diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index 03ca06459c..38c2aae9b9 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -6,8 +6,8 @@ use tauri::AppHandle; use crate::web::event_bridge::{emit_event, EventEmitter}; use super::types::{ - BrowserClosedPayload, BrowserPopupPayload, BrowserTabState, CLOSED_EVENT, POPUP_EVENT, - STATE_EVENT, + BrowserClosedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, + CLOSED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, STATE_EVENT, }; pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { @@ -28,3 +28,7 @@ pub fn emit_closed(app: &AppHandle, tab_id: &str, owner_window: &str) { pub fn emit_popup(app: &AppHandle, payload: &BrowserPopupPayload) { emit_event(&EventEmitter::Tauri(app.clone()), POPUP_EVENT, payload); } + +pub fn emit_open_request(app: &AppHandle, payload: &BrowserOpenRequestPayload) { + emit_event(&EventEmitter::Tauri(app.clone()), OPEN_REQUEST_EVENT, payload); +} diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index a298f8aea5..a793ff3628 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -361,6 +361,36 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .collect(); Ok(Value::Array(gestures)) } + // Ask the frontend to open a URL as a browser tab (exercises the real + // tab record → surface host → browser_open_tab path). + "frontend_open" => { + crate::browser::events::emit_open_request( + app, + &crate::browser::types::BrowserOpenRequestPayload { + url: str_arg(cmd, "url")?, + source: "smoke".to_string(), + activate: cmd.get("activate").and_then(Value::as_bool).unwrap_or(true), + owner_window: cmd.get("owner").and_then(Value::as_str).map(str::to_string), + }, + ); + Ok(Value::Null) + } + // Evaluate in the MAIN (workspace) webview — drives the frontend. + "main_eval" => { + let main = main_window()?; + let js = str_arg(cmd, "js")?; + let (tx, rx) = std::sync::mpsc::channel::<String>(); + main.eval_with_callback(&js, move |value| { + let _ = tx.send(value); + }) + .map_err(err_string)?; + let timeout = Duration::from_millis(cmd.get("timeout_ms").and_then(Value::as_u64).unwrap_or(8000)); + let value = tokio::task::spawn_blocking(move || rx.recv_timeout(timeout)) + .await + .map_err(err_string)? + .map_err(|_| "main eval timed out".to_string())?; + Ok(serde_json::from_str(&value).unwrap_or(Value::String(value))) + } "browser_focus" => { let surface = registry .surface(&str_arg(cmd, "tab_id")?) diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index 7dc5eb7fdf..a2a0280911 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -113,6 +113,21 @@ pub enum SurfaceChoice { pub const STATE_EVENT: &str = "browser://state"; pub const CLOSED_EVENT: &str = "browser://closed"; pub const POPUP_EVENT: &str = "browser://popup"; +/// Backend → frontend: please open this URL in a browser tab (agent tools, +/// deep links, the dev puppet). The frontend owns tab records, so a backend +/// side cannot create one directly. +pub const OPEN_REQUEST_EVENT: &str = "browser://open-request"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserOpenRequestPayload { + pub url: String, + /// Who asked: `agent`, `deeplink`, `smoke`, … + pub source: String, + pub activate: bool, + /// Window whose workspace should open it (`main` when absent). + pub owner_window: Option<String>, +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] diff --git a/src/app/globals.css b/src/app/globals.css index 15fed69e81..971238fe60 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2954,3 +2954,13 @@ div.monaco-diff-editor.side-by-side .editor.modified { .canvas-surface[data-canvas-panning] * { cursor: grabbing !important; } + +/* Built-in browser: indeterminate loading bar under the toolbar. */ +@keyframes browser-loading { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(300%); + } +} diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 35b1801342..c0951fc755 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => { }), adoptBrowserTab: vi.fn(() => "browser:opener-p1"), closeFileTab: vi.fn(), + openBrowserTab: vi.fn(() => "browser:new"), } }) @@ -45,6 +46,7 @@ vi.mock("@/contexts/workspace-context", () => ({ useWorkspaceActions: () => ({ adoptBrowserTab: mocks.adoptBrowserTab, closeFileTab: mocks.closeFileTab, + openBrowserTab: mocks.openBrowserTab, }), })) @@ -69,6 +71,7 @@ describe("BrowserEventsBridge", () => { mocks.subscribe.mockClear() mocks.adoptBrowserTab.mockClear() mocks.closeFileTab.mockClear() + mocks.openBrowserTab.mockClear() resetBrowserTabStoreForTests() }) afterEach(() => resetBrowserTabStoreForTests()) @@ -78,10 +81,29 @@ describe("BrowserEventsBridge", () => { await flush() expect([...mocks.handlers.keys()].sort()).toEqual([ "browser://closed", + "browser://open-request", "browser://popup", "browser://state", ]) + mocks.handlers.get("browser://open-request")!({ + url: "https://example.com/from-agent", + source: "agent", + activate: false, + ownerWindow: null, + }) + expect(mocks.openBrowserTab).toHaveBeenCalledWith( + "https://example.com/from-agent", + { activate: false } + ) + mocks.handlers.get("browser://open-request")!({ + url: "https://example.com/other-window", + source: "agent", + activate: true, + ownerWindow: "remote-workspace-3", + }) + expect(mocks.openBrowserTab).toHaveBeenCalledTimes(1) + mocks.handlers.get("browser://state")!({ tabId: "abc", ownerWindow: "main", @@ -153,6 +175,7 @@ describe("BrowserEventsBridge", () => { unmount() expect(mocks.unsubscribed.sort()).toEqual([ "browser://closed", + "browser://open-request", "browser://popup", "browser://state", ]) diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 5c759ab491..12ebe8ae56 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -7,17 +7,21 @@ import { browserCapabilities } from "@/lib/browser/browser-api" import { browserWorkspaceTabId, removeBrowserTabState, + setBrowserTabNotice, setBrowserTabState, } from "@/lib/browser/browser-tab-store" import { BROWSER_CLOSED_EVENT, + BROWSER_OPEN_REQUEST_EVENT, BROWSER_POPUP_EVENT, BROWSER_STATE_EVENT, type BrowserClosedPayload, + type BrowserOpenRequestPayload, type BrowserPopupPayload, type BrowserTabState, } from "@/lib/browser/types" import { getTransport } from "@/lib/transport" +import { getCurrentWindowLabel } from "@/lib/browser/window-label" /** * The one subscriber to the backend's `browser://*` streams. Mounted once @@ -28,12 +32,15 @@ import { getTransport } from "@/lib/transport" * - `browser://popup` → an adopted popup becomes a tab next to its opener * - `browser://closed` → a surface the backend tore down (owned window closed * by the user, owner window gone) drops its tab record + * - `browser://open-request` → the backend (an agent tool, a deep link, the + * dev puppet) asks this window's workspace to open a URL * * Only subscribes where a built-in browser exists; in web mode there is * nothing to hear. */ export function BrowserEventsBridge() { - const { adoptBrowserTab, closeFileTab } = useWorkspaceActions() + const { adoptBrowserTab, closeFileTab, openBrowserTab } = + useWorkspaceActions() useEffect(() => { let cancelled = false @@ -50,7 +57,15 @@ export function BrowserEventsBridge() { transport.subscribe<BrowserPopupPayload>( BROWSER_POPUP_EVENT, (popup) => { - if (popup.presentation !== "adopted" || !popup.tabId) return + if (popup.presentation === "denied") { + setBrowserTabNotice(browserWorkspaceTabId(popup.openerTabId), { + kind: "popup-denied", + url: popup.url, + reason: popup.reason, + }) + return + } + if (!popup.tabId) return adoptBrowserTab({ backendTabId: popup.tabId, url: popup.url, @@ -66,6 +81,15 @@ export function BrowserEventsBridge() { closeFileTab(tabId) } ), + transport.subscribe<BrowserOpenRequestPayload>( + BROWSER_OPEN_REQUEST_EVENT, + (request) => { + // Every window hears every event; only the addressed one acts. + const target = request.ownerWindow ?? "main" + if (target !== getCurrentWindowLabel()) return + openBrowserTab(request.url, { activate: request.activate }) + } + ), ]) if (cancelled) { for (const unsubscribe of subs) unsubscribe() @@ -78,7 +102,7 @@ export function BrowserEventsBridge() { cancelled = true for (const unsubscribe of unsubscribers) unsubscribe() } - }, [adoptBrowserTab, closeFileTab]) + }, [adoptBrowserTab, closeFileTab, openBrowserTab]) return null } diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx new file mode 100644 index 0000000000..d64416d251 --- /dev/null +++ b/src/components/browser/browser-status-layer.tsx @@ -0,0 +1,155 @@ +"use client" + +import { ExternalLink, RotateCw, ShieldAlert, X } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { browserReload } from "@/lib/browser/browser-api" +import { + setBrowserTabNotice, + useBrowserTabNotice, +} from "@/lib/browser/browser-tab-store" +import { displayHostPort } from "@/lib/browser/browser-url" +import type { BrowserErrorInfo, BrowserTabState } from "@/lib/browser/types" +import { browserTabBackendId } from "@/lib/file-tab-id" +import { openUrl } from "@/lib/platform" + +/** Bars that sit OUTSIDE the native surface's rect (a native view paints over + * any DOM placed on top of it): blocked popups, remote-egress banner. */ +export function BrowserNoticeBar({ + tab, + state, +}: { + tab: BrowserWorkspaceTab + state: BrowserTabState | null +}) { + const t = useTranslations("Browser.status") + const notice = useBrowserTabNotice(tab.id) + if (!notice && !state?.remoteHost) return null + return ( + <div className="flex flex-col"> + {state?.remoteHost ? ( + <div className="flex h-7 items-center gap-2 border-b border-border/60 bg-muted/60 px-3 text-xs text-muted-foreground"> + {t("remoteBanner", { host: state.remoteHost })} + </div> + ) : null} + {notice ? ( + <div className="flex h-8 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 text-xs text-foreground"> + <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-amber-600" /> + <span className="min-w-0 flex-1 truncate"> + {t("popupDenied", { + host: displayHostPort(notice.url) ?? notice.url, + })} + {notice.reason === "no-gesture" + ? ` · ${t("popupDeniedNoGesture")}` + : notice.reason === "blocked-scheme" + ? ` · ${t("popupDeniedBlockedScheme")}` + : ""} + </span> + <button + type="button" + className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-primary/8" + title={t("dismiss")} + aria-label={t("dismiss")} + onClick={() => setBrowserTabNotice(tab.id, null)} + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ) : null} + </div> + ) +} + +function errorLabel( + t: ReturnType<typeof useTranslations<"Browser.status">>, + error: BrowserErrorInfo +): string { + switch (error.kind) { + case "dns": + return t("errorDns") + case "tls": + return t("errorTls") + case "blocked": + return t("errorBlocked") + case "popup-denied": + return t("errorPopupDenied") + default: + return t("errorFailed") + } +} + +/** DOM error page shown INSTEAD of the native surface (the host hides it). */ +export function BrowserErrorPage({ + tab, + error, + url, +}: { + tab: BrowserWorkspaceTab + error: BrowserErrorInfo + url: string +}) { + const t = useTranslations("Browser.status") + const backendId = browserTabBackendId(tab.id) + return ( + <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <ShieldAlert className="h-8 w-8 text-muted-foreground/60" /> + <p className="text-sm font-medium text-foreground"> + {errorLabel(t, error)} + </p> + <p className="max-w-md break-all text-xs text-muted-foreground"> + {error.url ?? url} + </p> + {error.message ? ( + <p className="max-w-md text-xs text-muted-foreground/80"> + {error.message} + </p> + ) : null} + <div className="mt-1 flex items-center gap-2"> + <button + type="button" + className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" + onClick={() => backendId && void browserReload(backendId)} + > + <RotateCw className="h-3.5 w-3.5" /> + {t("retry")} + </button> + <button + type="button" + className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" + onClick={() => void openUrl(error.url ?? url)} + > + <ExternalLink className="h-3.5 w-3.5" /> + {t("openInSystem")} + </button> + </div> + </div> + ) +} + +/** Placeholder shown in the pane when the page lives in an owned window + * (Linux, or the fallback surface): nothing is embedded here. */ +export function BrowserOwnedWindowCard({ + url, + onShow, +}: { + url: string + onShow: () => void +}) { + const t = useTranslations("Browser.status") + return ( + <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <p className="text-sm text-muted-foreground">{t("ownedWindow")}</p> + <p className="max-w-md break-all text-xs text-muted-foreground/80"> + {url} + </p> + <button + type="button" + className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" + onClick={onShow} + > + {t("ownedWindowShow")} + </button> + </div> + ) +} diff --git a/src/components/browser/browser-surface-host.test.tsx b/src/components/browser/browser-surface-host.test.tsx new file mode 100644 index 0000000000..d411b4d3d7 --- /dev/null +++ b/src/components/browser/browser-surface-host.test.tsx @@ -0,0 +1,159 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import type { BrowserTabState } from "@/lib/browser/types" + +const api = vi.hoisted(() => ({ + browserOpenTab: vi.fn(), + browserSetBounds: vi.fn(() => Promise.resolve()), + browserSetVisible: vi.fn(() => Promise.resolve()), +})) +vi.mock("@/lib/browser/browser-api", () => api) +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceView: () => ({ + mode: "conversation", + activePane: "files", + filesMaximized: false, + }), +})) +vi.mock("@/contexts/workbench-route-context", () => ({ + useOptionalWorkbenchRoute: () => null, +})) +vi.mock("@/components/ui/overlay-host-hidden", () => ({ + useOverlayHostHidden: () => false, +})) + +import { BrowserSurfaceHost } from "./browser-surface-host" +import { resetBrowserTabStoreForTests } from "@/lib/browser/browser-tab-store" +import { + acquireNativeSurfaceOcclusion, + resetNativeSurfaceOcclusionForTests, +} from "@/lib/browser/native-surface-occlusion" + +function tab(id = "abc"): BrowserWorkspaceTab { + return { + id: `browser:${id}`, + kind: "browser", + folderId: 1, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: true, + readonly: true, + browser: { initialUrl: "https://example.com/", openerTabId: null }, + } +} + +function state(id = "abc"): BrowserTabState { + return { + tabId: id, + ownerWindow: "main", + surface: "child", + channel: "degraded", + url: "", + requestedUrl: "https://example.com/", + title: "", + favicon: null, + loading: true, + canGoBack: false, + canGoForward: false, + origin: null, + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + } +} + +async function flush() { + await act(async () => { + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe("BrowserSurfaceHost", () => { + beforeEach(() => { + api.browserOpenTab.mockReset() + api.browserSetBounds.mockClear() + api.browserSetVisible.mockClear() + resetBrowserTabStoreForTests() + resetNativeSurfaceOcclusionForTests() + // jsdom has no layout: give the host a rect. + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 50, + left: 100, + top: 50, + width: 800, + height: 600, + right: 900, + bottom: 650, + toJSON: () => ({}), + }) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it("creates the surface once at its rect, hides it under an overlay lease, and hides on unmount", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host1"))) + const { unmount } = render(<BrowserSurfaceHost tab={tab("host1")} />) + await flush() + expect(api.browserOpenTab).toHaveBeenCalledTimes(1) + expect(api.browserOpenTab.mock.calls[0][0]).toMatchObject({ + tabId: "host1", + url: "https://example.com/", + bounds: { x: 100, y: 50, width: 800, height: 600 }, + }) + + let release: () => void = () => {} + await act(async () => { + release = acquireNativeSurfaceOcclusion("dialog") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + // Hidden with focus handoff. + expect(api.browserSetVisible).toHaveBeenLastCalledWith("host1", false, true) + + await act(async () => { + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(api.browserSetVisible).toHaveBeenLastCalledWith("host1", true, false) + + unmount() + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host1", + false, + false + ) + }) + + it("does not create a second surface for a tab that already has one", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host2"))) + const first = render(<BrowserSurfaceHost tab={tab("host2")} />) + await flush() + first.unmount() + render(<BrowserSurfaceHost tab={tab("host2")} />) + await flush() + expect(api.browserOpenTab).toHaveBeenCalledTimes(1) + // Re-mount re-applies bounds and shows the existing surface. + expect(api.browserSetBounds).toHaveBeenCalledWith("host2", { + x: 100, + y: 50, + width: 800, + height: 600, + }) + expect(api.browserSetVisible).toHaveBeenLastCalledWith("host2", true, false) + }) + + it("stays hidden while the view is force-hidden (error page)", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host3"))) + render(<BrowserSurfaceHost tab={tab("host3")} hidden />) + await flush() + expect(api.browserSetVisible).toHaveBeenLastCalledWith("host3", false, true) + }) +}) diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx new file mode 100644 index 0000000000..6ada680981 --- /dev/null +++ b/src/components/browser/browser-surface-host.tsx @@ -0,0 +1,209 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { useWorkspaceView } from "@/contexts/workspace-context" +import { useOptionalWorkbenchRoute } from "@/contexts/workbench-route-context" +import { useOverlayHostHidden } from "@/components/ui/overlay-host-hidden" +import { + browserOpenTab, + browserSetBounds, + browserSetVisible, +} from "@/lib/browser/browser-api" +import { + getBrowserTabState, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { + useFallbackOverlayOpen, + useNativeSurfaceOccluded, +} from "@/lib/browser/native-surface-occlusion" +import type { Bounds } from "@/lib/browser/types" +import { browserTabBackendId } from "@/lib/file-tab-id" +import { cn } from "@/lib/utils" + +/** How often the host re-checks CSS visibility it cannot observe otherwise + * (a `visibility: hidden` ancestor toggled by the layout). One + * `checkVisibility()` call per tick. */ +const VISIBILITY_POLL_MS = 500 + +// Backend ids whose surface this window has asked to create. Guards the +// StrictMode double-effect and re-mounts of the same tab: the webview lives +// as long as the tab record, not as long as this component. +const created = new Set<string>() + +function measure(el: HTMLElement): Bounds { + const rect = el.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } +} + +function sameBounds(a: Bounds | null, b: Bounds): boolean { + if (!a) return false + return ( + Math.abs(a.x - b.x) < 0.5 && + Math.abs(a.y - b.y) < 0.5 && + Math.abs(a.width - b.width) < 0.5 && + Math.abs(a.height - b.height) < 0.5 + ) +} + +function elementVisible(el: HTMLElement): boolean { + if (typeof el.checkVisibility === "function") { + return el.checkVisibility({ visibilityProperty: true } as never) + } + return true +} + +/** + * The placeholder a browser tab's native webview is fitted to. + * + * The webview is a native view painted by the OS above the DOM at this + * element's rect, so this component never renders page content itself. It + * (1) creates the surface on first mount, (2) keeps the webview's bounds equal + * to its own rect, and (3) hides the webview whenever the rect is not really + * visible — the file column is CSS-hidden in conversation mode, an overlay + * holds an occlusion lease, the tab is showing an error page — handing + * keyboard focus back to the main webview first so the overlay gets Esc/Tab. + */ +export function BrowserSurfaceHost({ + tab, + hidden = false, + className, +}: { + tab: BrowserWorkspaceTab + /** Force-hide (e.g. while a DOM error page replaces the page). */ + hidden?: boolean + className?: string +}) { + const backendId = browserTabBackendId(tab.id) + const ref = useRef<HTMLDivElement | null>(null) + const lastBoundsRef = useRef<Bounds | null>(null) + const lastVisibleRef = useRef<boolean | null>(null) + const [createError, setCreateError] = useState<string | null>(null) + const occluded = useNativeSurfaceOccluded() + const fallbackOverlay = useFallbackOverlayOpen() + const view = useWorkspaceView() + const route = useOptionalWorkbenchRoute() + const routeVisible = route ? route.isConversations : true + // The whole workspace surface is CSS-hidden under a full-page route. + const hostHidden = useOverlayHostHidden() + const shouldShow = + !hidden && !occluded && !fallbackOverlay && routeVisible && !hostHidden + + // One pass: measure, push bounds if they moved, push visibility if it + // flipped. Called from every signal that could change either. + const sync = useCallback(() => { + const el = ref.current + if (!el || !backendId) return + const bounds = measure(el) + const visible = + shouldShow && bounds.width > 0 && bounds.height > 0 && elementVisible(el) + if (visible && !sameBounds(lastBoundsRef.current, bounds)) { + lastBoundsRef.current = bounds + void browserSetBounds(backendId, bounds).catch(() => {}) + } + if (lastVisibleRef.current !== visible) { + lastVisibleRef.current = visible + void browserSetVisible(backendId, visible, !visible).catch(() => {}) + } + }, [backendId, shouldShow]) + + // Create the surface once per tab record; adopted popups and re-mounts + // already have one (the store knows about it). + useEffect(() => { + const el = ref.current + if (!el || !backendId) return + if (created.has(backendId) || getBrowserTabState(tab.id)) { + lastBoundsRef.current = null + lastVisibleRef.current = null + sync() + return + } + created.add(backendId) + const bounds = measure(el) + lastBoundsRef.current = bounds + lastVisibleRef.current = true + browserOpenTab({ + tabId: backendId, + url: tab.browser.initialUrl, + bounds, + folderId: tab.folderId, + }) + .then((next) => { + setBrowserTabState(next) + sync() + }) + .catch((error: unknown) => { + created.delete(backendId) + setCreateError(String(error)) + }) + // Intentionally not re-run on `sync` identity changes: creation is a + // one-shot per mount, the effect below handles every later sync. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [backendId, tab.id, tab.browser.initialUrl, tab.folderId]) + + // Geometry and visibility tracking for the life of the mount. + useEffect(() => { + const el = ref.current + if (!el || !backendId) return + let frame = 0 + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + sync() + }) + } + schedule() + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(schedule) + : null + observer?.observe(el) + window.addEventListener("resize", schedule) + const poll = window.setInterval(sync, VISIBILITY_POLL_MS) + return () => { + if (frame) cancelAnimationFrame(frame) + observer?.disconnect() + window.removeEventListener("resize", schedule) + window.clearInterval(poll) + } + }, [backendId, sync]) + + // Layout-driven visibility flips (pane / mode / route) re-sync at once + // instead of waiting for the poll. + useEffect(() => { + sync() + }, [sync, view.mode, view.activePane, view.filesMaximized, routeVisible]) + + // Unmount: the tab is no longer on screen (another tab took the pane, the + // drawer closed, the panel went away). Hide, never destroy — the record + // owns the surface. + useEffect(() => { + if (!backendId) return + return () => { + lastVisibleRef.current = null + void browserSetVisible(backendId, false, false).catch(() => {}) + } + }, [backendId]) + + return ( + <div + ref={ref} + data-browser-surface={backendId ?? undefined} + data-browser-visible={lastVisibleRef.current ? "true" : "false"} + className={cn( + "relative h-full w-full min-h-0 min-w-0 bg-background", + className + )} + aria-hidden + > + {createError ? ( + <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-destructive"> + {createError} + </div> + ) : null} + </div> + ) +} diff --git a/src/components/browser/browser-tab-view.tsx b/src/components/browser/browser-tab-view.tsx new file mode 100644 index 0000000000..9f0b74ff9a --- /dev/null +++ b/src/components/browser/browser-tab-view.tsx @@ -0,0 +1,57 @@ +"use client" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { browserSetVisible } from "@/lib/browser/browser-api" +import { useBrowserTabState } from "@/lib/browser/browser-tab-store" +import { browserTabBackendId } from "@/lib/file-tab-id" + +import { + BrowserErrorPage, + BrowserNoticeBar, + BrowserOwnedWindowCard, +} from "./browser-status-layer" +import { BrowserSurfaceHost } from "./browser-surface-host" +import { BrowserToolbar } from "./browser-toolbar" + +/** + * The file-pane content of a browser tab: toolbar, notices, and the native + * surface (or, when the page could not load, a DOM error page in its place). + */ +export function BrowserTabView({ tab }: { tab: BrowserWorkspaceTab }) { + const state = useBrowserTabState(tab.id) + const backendId = browserTabBackendId(tab.id) + const url = state?.url || state?.requestedUrl || tab.browser.initialUrl + const error = state?.error ?? null + const ownedWindow = state?.surface === "window" + + return ( + <div className="flex h-full min-h-0 flex-col"> + <BrowserToolbar tab={tab} state={state} /> + <BrowserNoticeBar tab={tab} state={state} /> + <div className="relative min-h-0 flex-1"> + {/* Always mounted so the native surface keeps its bounds; the DOM + layers below only show when the surface is hidden (error) or + never embedded (owned window). */} + <BrowserSurfaceHost + tab={tab} + hidden={error !== null} + className={error || ownedWindow ? "invisible" : undefined} + /> + {error ? ( + <div className="absolute inset-0 bg-background"> + <BrowserErrorPage tab={tab} error={error} url={url} /> + </div> + ) : ownedWindow ? ( + <div className="absolute inset-0 bg-background"> + <BrowserOwnedWindowCard + url={url} + onShow={() => + backendId && void browserSetVisible(backendId, true, false) + } + /> + </div> + ) : null} + </div> + </div> + ) +} diff --git a/src/components/browser/browser-toolbar.test.tsx b/src/components/browser/browser-toolbar.test.tsx new file mode 100644 index 0000000000..5f2499858f --- /dev/null +++ b/src/components/browser/browser-toolbar.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import { normalizeTypedAddress } from "./browser-toolbar" + +describe("normalizeTypedAddress", () => { + it("keeps full http(s) URLs and normalizes them", () => { + expect(normalizeTypedAddress(" https://Example.com/a b ")).toBe( + "https://example.com/a%20b" + ) + expect(normalizeTypedAddress("http://localhost:3000")).toBe( + "http://localhost:3000/" + ) + }) + + it("adds a scheme to bare hosts: http for local, https otherwise", () => { + expect(normalizeTypedAddress("localhost:3000/app")).toBe( + "http://localhost:3000/app" + ) + expect(normalizeTypedAddress("127.0.0.1:8080")).toBe( + "http://127.0.0.1:8080/" + ) + expect(normalizeTypedAddress("192.168.1.5")).toBe("http://192.168.1.5/") + expect(normalizeTypedAddress("example.com/docs?x=1")).toBe( + "https://example.com/docs?x=1" + ) + }) + + it("refuses other schemes, words and blanks (no search fallback)", () => { + expect(normalizeTypedAddress("javascript:alert(1)")).toBeNull() + expect(normalizeTypedAddress("file:///etc/hosts")).toBeNull() + expect(normalizeTypedAddress("hello world")).toBeNull() + expect(normalizeTypedAddress("notes")).toBeNull() + expect(normalizeTypedAddress("")).toBeNull() + }) +}) diff --git a/src/components/browser/browser-toolbar.tsx b/src/components/browser/browser-toolbar.tsx new file mode 100644 index 0000000000..427262e1f5 --- /dev/null +++ b/src/components/browser/browser-toolbar.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useRef, useState, type KeyboardEvent } from "react" +import { + ArrowLeft, + ArrowRight, + Copy, + ExternalLink, + RotateCw, + X, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { + browserGoBack, + browserGoForward, + browserNavigate, + browserReload, + browserStop, +} from "@/lib/browser/browser-api" +import type { BrowserTabState } from "@/lib/browser/types" +import { browserTabBackendId } from "@/lib/file-tab-id" +import { openUrl } from "@/lib/platform" +import { cn, copyTextToClipboard } from "@/lib/utils" + +const ICON_BTN = + "flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-primary/8 hover:text-foreground disabled:pointer-events-none disabled:opacity-40" + +/** + * Turn what a user typed into something the tab can load: a full URL as is, + * a bare host / host:port / IP with an https (or http for loopback) prefix. + * No search-engine fallback — the address bar is an address bar. + */ +export function normalizeTypedAddress(raw: string): string | null { + const text = raw.trim() + if (!text) return null + if (/^https?:\/\//i.test(text)) { + try { + return new URL(text).toString() + } catch { + return null + } + } + // A scheme other than http(s) is refused — but `localhost:3000/app` is a + // host with a port, not a scheme, so a colon followed by digits is fine. + if (/^[a-z][a-z\d+\-.]*:(?!\d)/i.test(text)) return null + if (/\s/.test(text)) return null + const hostPart = text.split(/[/?#]/)[0] + if ( + !hostPart.includes(".") && + !/^(localhost|\[?[\d:.]+\]?)(:\d+)?$/i.test(hostPart) + ) { + return null + } + const isLocal = /^(localhost|127\.|\[::1\]|0\.0\.0\.0|10\.|192\.168\.)/i.test( + hostPart + ) + try { + return new URL(`${isLocal ? "http" : "https"}://${text}`).toString() + } catch { + return null + } +} + +export function BrowserToolbar({ + tab, + state, +}: { + tab: BrowserWorkspaceTab + state: BrowserTabState | null +}) { + const t = useTranslations("Browser.toolbar") + const backendId = browserTabBackendId(tab.id) + const currentUrl = state?.url || state?.requestedUrl || tab.browser.initialUrl + const [draft, setDraft] = useState(currentUrl) + const [editing, setEditing] = useState(false) + const [mirroredUrl, setMirroredUrl] = useState(currentUrl) + const inputRef = useRef<HTMLInputElement | null>(null) + + // Mirror the live URL unless the user is typing. Adjusted during render + // (the "state from previous renders" pattern) rather than in an effect, so + // the address bar never paints a stale URL for a frame. + if (mirroredUrl !== currentUrl) { + setMirroredUrl(currentUrl) + if (!editing) setDraft(currentUrl) + } + + const loading = state?.loading ?? true + + const submit = () => { + if (!backendId) return + const url = normalizeTypedAddress(draft) + if (!url) { + toast.error(t("invalidUrl")) + return + } + setEditing(false) + inputRef.current?.blur() + void browserNavigate(backendId, url).catch((error: unknown) => { + toast.error(t("invalidUrl"), { description: String(error) }) + }) + } + + const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { + if (event.key === "Enter") { + event.preventDefault() + submit() + } else if (event.key === "Escape") { + event.preventDefault() + setDraft(currentUrl) + setEditing(false) + inputRef.current?.blur() + } + } + + return ( + <div className="relative flex h-9 shrink-0 items-center gap-1 border-b border-border/60 bg-muted/40 px-1.5"> + <button + type="button" + className={ICON_BTN} + title={t("back")} + aria-label={t("back")} + disabled={!backendId || !state?.canGoBack} + onClick={() => backendId && void browserGoBack(backendId)} + > + <ArrowLeft className="h-4 w-4" /> + </button> + <button + type="button" + className={ICON_BTN} + title={t("forward")} + aria-label={t("forward")} + disabled={!backendId || !state?.canGoForward} + onClick={() => backendId && void browserGoForward(backendId)} + > + <ArrowRight className="h-4 w-4" /> + </button> + <button + type="button" + className={ICON_BTN} + title={loading ? t("stop") : t("reload")} + aria-label={loading ? t("stop") : t("reload")} + disabled={!backendId} + onClick={() => + backendId && + void (loading ? browserStop(backendId) : browserReload(backendId)) + } + > + {loading ? <X className="h-4 w-4" /> : <RotateCw className="h-4 w-4" />} + </button> + <input + ref={inputRef} + value={draft} + onChange={(event) => setDraft(event.target.value)} + onFocus={(event) => { + setEditing(true) + event.currentTarget.select() + }} + onBlur={() => setEditing(false)} + onKeyDown={onKeyDown} + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + placeholder={t("addressPlaceholder")} + aria-label={t("addressPlaceholder")} + className={cn( + "mx-1 h-7 min-w-0 flex-1 rounded-md border border-transparent bg-background px-2.5 text-xs text-foreground outline-none", + "focus:border-ring/50 focus:ring-2 focus:ring-ring/20" + )} + /> + <button + type="button" + className={ICON_BTN} + title={t("copyUrl")} + aria-label={t("copyUrl")} + onClick={() => { + void copyTextToClipboard(currentUrl).then(() => + toast.success(t("copied")) + ) + }} + > + <Copy className="h-3.5 w-3.5" /> + </button> + <button + type="button" + className={ICON_BTN} + title={t("openInSystem")} + aria-label={t("openInSystem")} + onClick={() => void openUrl(currentUrl)} + > + <ExternalLink className="h-3.5 w-3.5" /> + </button> + {loading ? ( + <div + aria-hidden + className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden" + > + <div className="h-full w-1/3 animate-[browser-loading_1.2s_ease-in-out_infinite] bg-primary" /> + </div> + ) : null} + </div> + ) +} diff --git a/src/components/browser/browser-viewer-drawer.tsx b/src/components/browser/browser-viewer-drawer.tsx new file mode 100644 index 0000000000..d7c59fc8ff --- /dev/null +++ b/src/components/browser/browser-viewer-drawer.tsx @@ -0,0 +1,116 @@ +"use client" + +import { useEffect } from "react" +import { useTranslations } from "next-intl" +import { PanelRightOpen } from "lucide-react" + +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerTitle, + SIDE_PANEL_CONTENT_CLASS, +} from "@/components/ui/drawer" +import { useOptionalWorkbenchRoute } from "@/contexts/workbench-route-context" +import { + useWorkspaceActions, + useWorkspaceFileTabs, +} from "@/contexts/workspace-context" +import { normalizeUrlForDedupe } from "@/lib/browser/browser-url" + +import { BrowserTabView } from "./browser-tab-view" + +/** + * The built-in browser inside the transcript's side panel — where an http(s) + * link lands when a full-page route (task board, canvas, forge) covers the + * file column. Underneath it is the same workspace browser tab: the drawer + * opens (or re-uses) the tab record without activating the file column, shows + * its view here, and "open in workspace" leads back to the column. + */ +export function BrowserViewerDrawer({ + url, + open, + onOpenChange, +}: { + url: string + open: boolean + onOpenChange: (open: boolean) => void +}) { + const t = useTranslations("Browser.drawer") + return ( + <Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right"> + <DrawerContent + closeButtonClassName="top-2.5 right-3" + className={SIDE_PANEL_CONTENT_CLASS} + nativeSurfaceHost + > + <DrawerTitle className="sr-only">{t("title")}</DrawerTitle> + <DrawerDescription className="sr-only"> + {t("description")} + </DrawerDescription> + {open ? ( + <BrowserViewerBody url={url} onOpenChange={onOpenChange} /> + ) : null} + </DrawerContent> + </Drawer> + ) +} + +function BrowserViewerBody({ + url, + onOpenChange, +}: { + url: string + onOpenChange: (open: boolean) => void +}) { + const t = useTranslations("Browser.drawer") + const { openBrowserTab, switchFileTab } = useWorkspaceActions() + const { fileTabs } = useWorkspaceFileTabs() + const route = useOptionalWorkbenchRoute() + + // Open (or re-use) the workspace tab without activating the file column; + // the record shows up in `fileTabs` and is found by its URL below, so no + // local state is needed. + useEffect(() => { + openBrowserTab(url, { activate: false }) + }, [openBrowserTab, url]) + + const wanted = normalizeUrlForDedupe(url) + const tab = fileTabs.find( + (it) => + it.kind === "browser" && + normalizeUrlForDedupe(it.browser.initialUrl) === wanted + ) + const tabId = tab?.id ?? null + + return ( + <div className="flex h-full min-h-0 flex-col"> + <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/60 px-3 pr-12 text-xs text-muted-foreground"> + <span className="min-w-0 flex-1 truncate">{url}</span> + {route && tabId ? ( + <button + type="button" + className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-border px-2 text-xs text-foreground hover:bg-primary/8" + onClick={() => { + route.openConversations() + switchFileTab(tabId) + onOpenChange(false) + }} + > + <PanelRightOpen className="h-3.5 w-3.5" /> + {t("openInWorkspace")} + </button> + ) : null} + </div> + <div className="min-h-0 flex-1"> + {tab?.kind === "browser" ? ( + <BrowserTabView key={tab.id} tab={tab} /> + ) : ( + <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground"> + {t("cannotOpen")} + </div> + )} + </div> + </div> + ) +} diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 0e1982e432..9b0b02d8e8 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -26,6 +26,7 @@ import { useWorkspaceFileTabs, type FileWorkspaceTab, } from "@/contexts/workspace-context" +import { BrowserTabView } from "@/components/browser/browser-tab-view" import { ImagePreview } from "@/components/files/image-preview" import { HtmlPreview } from "@/components/files/html-preview" import { MarkdownDocumentPreview } from "@/components/files/markdown-document-preview" @@ -203,6 +204,8 @@ function createAddToChatPill( // matching VS Code / IntelliJ "non-destructive refresh" behaviour. Only // a true cold load (no content yet) falls back to the full-pane placeholder. function hasTabContent(tab: FileWorkspaceTab): boolean { + // A browser tab has no text content; its page lives in a native surface. + if (tab.kind === "browser") return true if (tab.kind === "rich-diff") { return ( tab.originalContent !== undefined || @@ -1710,6 +1713,12 @@ export function FileWorkspacePanel() { ) } + if (activeFileTab.kind === "browser") { + // Keyed by tab so switching between two browser tabs remounts the surface + // host (which hides the old webview and shows the new one). + return <BrowserTabView key={activeFileTab.id} tab={activeFileTab} /> + } + if (activeFileTab.kind === "rich-diff") { const richDiffParts = parseFileTabId(activeFileTab.id) const isCommitDiff = richDiffParts?.kind === "diff-commit" diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 880c03279a..40177321bb 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -2,13 +2,21 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Reorder } from "motion/react" -import { FileText, GitCompare, Maximize2, Minimize2, X } from "lucide-react" +import { + FileText, + GitCompare, + Maximize2, + Minimize2, + X, + Globe, +} from "lucide-react" import { useTranslations } from "next-intl" import { useWorkspaceActions, useWorkspaceFileTabs, useWorkspaceView, } from "@/contexts/workspace-context" +import { useBrowserTabState } from "@/lib/browser/browser-tab-store" import type { FileWorkspaceTab } from "@/contexts/workspace-context" import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer" import { useLongPressDrag } from "@/hooks/use-long-press-drag" @@ -219,7 +227,15 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ onTouchSortingEnd, }: FileWorkspaceTabItemProps) { const isDiff = tab.kind === "diff" || tab.kind === "rich-diff" + const isBrowser = tab.kind === "browser" const isDirty = tab.kind === "file" && Boolean(tab.isDirty) + // A browser tab's title follows the page (document.title); the record only + // knows the host it was opened with. + const browserState = useBrowserTabState(isBrowser ? tab.id : null) + const displayTitle = isBrowser ? browserState?.title || tab.title : tab.title + const displayHint = isBrowser + ? browserState?.url || tab.browser.initialUrl + : (tab.description ?? tab.title) const handleLongPressStart = useCallback( () => onTouchSortingStart(tab.id), @@ -317,9 +333,11 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ : "text-muted-foreground", ] )} - title={tab.description ?? tab.title} + title={displayHint} > - {isDiff ? ( + {isBrowser ? ( + <Globe className="h-3.5 w-3.5" /> + ) : isDiff ? ( <GitCompare className="h-3.5 w-3.5" /> ) : ( <FileText className="h-3.5 w-3.5" /> @@ -335,7 +353,7 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ : "truncate max-w-[11.25rem]" )} > - {tab.title} + {displayTitle} {isDirty ? " *" : ""} </span> <button diff --git a/src/components/message/session-viewer-host-context.ts b/src/components/message/session-viewer-host-context.ts index d5ffec3769..52b5ce53e4 100644 --- a/src/components/message/session-viewer-host-context.ts +++ b/src/components/message/session-viewer-host-context.ts @@ -69,10 +69,19 @@ export interface FileRequest extends FileViewerRequest { kind: "file" } +/** An http(s) page opened from the transcript while the file column is + * covered by a full-page route: shown in the built-in browser, inside the + * transcript's side panel. */ +export interface BrowserRequest { + kind: "browser" + url: string +} + export type SessionViewerRequest = | DelegationRequest | AgentSessionRequest | FileRequest + | BrowserRequest export interface SessionViewerHostValue { open: (request: SessionViewerRequest) => void diff --git a/src/components/message/session-viewer-host.tsx b/src/components/message/session-viewer-host.tsx index 3f62f1ed03..f976447570 100644 --- a/src/components/message/session-viewer-host.tsx +++ b/src/components/message/session-viewer-host.tsx @@ -27,6 +27,7 @@ import * as React from "react" +import { BrowserViewerDrawer } from "@/components/browser/browser-viewer-drawer" import { FileViewerDrawer } from "@/components/files/file-viewer-drawer" import { SessionViewerHostContext, @@ -100,6 +101,14 @@ export function SessionViewerHost({ children }: { children: React.ReactNode }) { onOpenChange={setOpen} /> )} + {request?.kind === "browser" && ( + <BrowserViewerDrawer + key={request.url} + url={request.url} + open={open} + onOpenChange={setOpen} + /> + )} </SessionViewerHostContext.Provider> ) } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 7468eaca0a..6f4a4c86b6 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -5,6 +5,7 @@ import { AlertDialog as AlertDialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" function AlertDialog({ ...props @@ -51,6 +52,8 @@ function AlertDialogContent({ }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & { size?: "default" | "sm" }) { + // A native browser surface would paint over this overlay; hide it while open. + useNativeSurfaceOcclusion("alert-dialog") return ( <AlertDialogPortal> <AlertDialogOverlay /> diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 6af386bb70..18056f3bc3 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { ContextMenu as ContextMenuPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" function ContextMenu({ ...props @@ -23,6 +24,8 @@ function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) { + // A native browser surface would paint over this overlay; hide it while open. + useNativeSurfaceOcclusion("context-menu") return ( <ContextMenuPrimitive.Portal> <ContextMenuPrimitive.Content diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index c34bcf00ed..01f5e973d3 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { OverlayPortalContainerProvider } from "@/components/ui/overlay-portal-container" import { useNestedLayerDismissGuard } from "@/hooks/use-nested-layer-dismiss-guard" import { cn } from "@/lib/utils" +import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" function Dialog({ ...props @@ -61,6 +62,8 @@ function DialogContent({ closeButtonClassName?: string showCloseButton?: boolean }) { + // A native browser surface would paint over this overlay; hide it while open. + useNativeSurfaceOcclusion("dialog") // Without this, closing a nested Select/DropdownMenu by clicking elsewhere in // the dialog closes the dialog too. const { diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 6e42cad6c1..a8613d278b 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -9,6 +9,7 @@ import { attachRef } from "@/lib/attach-ref" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" +import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" type DrawerContextProps = { hasSnapPoints: boolean @@ -264,12 +265,18 @@ function DrawerContent({ children, closeButtonClassName, showCloseButton = true, + nativeSurfaceHost = false, ref, ...props }: DrawerPrimitive.Popup.Props & { closeButtonClassName?: string showCloseButton?: boolean + /** This drawer CONTAINS a built-in browser surface (the transcript's + * browser viewer): it must not take the lease that would hide it. */ + nativeSurfaceHost?: boolean }) { + // A native browser surface would paint over this overlay; hide it while open. + useNativeSurfaceOcclusion("drawer", !nativeSurfaceHost) const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer() const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x" diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c99a978ba4..7453422cc0 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -5,6 +5,7 @@ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { CheckIcon, ChevronRightIcon } from "lucide-react" +import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" function DropdownMenu({ ...props @@ -37,6 +38,8 @@ function DropdownMenuContent({ sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { + // A native browser surface would paint over this overlay; hide it while open. + useNativeSurfaceOcclusion("dropdown-menu") return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 437945dcb1..b4ef01d006 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "حذف", "canvasActions": "إجراءات اللوحة", "mergeIntoNewRegion": "منطقة جديدة" + }, + "Browser": { + "toolbar": { + "back": "رجوع", + "forward": "تقدّم", + "reload": "إعادة تحميل", + "stop": "إيقاف", + "openInSystem": "فتح في متصفح النظام", + "copyUrl": "نسخ الرابط", + "copied": "تم نسخ الرابط", + "addressPlaceholder": "أدخل عنوانًا", + "invalidUrl": "عنوان غير صالح" + }, + "status": { + "popupDenied": "تم حظر نافذة منبثقة: {host}", + "popupDeniedNoGesture": "لم تُفتح بنقرة", + "popupDeniedBlockedScheme": "نوع العنوان غير مسموح به", + "dismiss": "إغلاق", + "errorDns": "تعذّر العثور على هذا الموقع", + "errorTls": "هذا الاتصال غير آمن", + "errorBlocked": "هذا العنوان محظور في المتصفح المدمج", + "errorPopupDenied": "تم حظر النافذة المنبثقة", + "errorFailed": "تعذّر تحميل هذه الصفحة", + "retry": "إعادة المحاولة", + "openInSystem": "فتح في متصفح النظام", + "ownedWindow": "تُعرض هذه الصفحة في نافذة مستقلة.", + "ownedWindowShow": "إظهار النافذة", + "remoteBanner": "فُتح عبر {host}" + }, + "tab": { + "untitled": "علامة تبويب جديدة" + }, + "drawer": { + "title": "المتصفح المدمج", + "description": "صفحة ويب فُتحت من المحادثة", + "openInWorkspace": "فتح في مساحة العمل", + "cannotOpen": "لا يمكن فتح هذا العنوان هنا." + } } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9e80735d19..9ccc937cbc 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "Löschen", "canvasActions": "Leinwand-Aktionen", "mergeIntoNewRegion": "Neuer Bereich" + }, + "Browser": { + "toolbar": { + "back": "Zurück", + "forward": "Vor", + "reload": "Neu laden", + "stop": "Stopp", + "openInSystem": "Im Systembrowser öffnen", + "copyUrl": "Link kopieren", + "copied": "Link kopiert", + "addressPlaceholder": "Adresse eingeben", + "invalidUrl": "Keine gültige Adresse" + }, + "status": { + "popupDenied": "Pop-up blockiert: {host}", + "popupDeniedNoGesture": "nicht durch einen Klick geöffnet", + "popupDeniedBlockedScheme": "Adresstyp nicht erlaubt", + "dismiss": "Schließen", + "errorDns": "Diese Website wurde nicht gefunden", + "errorTls": "Diese Verbindung ist nicht sicher", + "errorBlocked": "Diese Adresse ist im integrierten Browser gesperrt", + "errorPopupDenied": "Pop-up blockiert", + "errorFailed": "Diese Seite kann nicht geladen werden", + "retry": "Erneut versuchen", + "openInSystem": "Im Systembrowser öffnen", + "ownedWindow": "Diese Seite wird in einem eigenen Fenster angezeigt.", + "ownedWindowShow": "Fenster anzeigen", + "remoteBanner": "Geöffnet über {host}" + }, + "tab": { + "untitled": "Neuer Tab" + }, + "drawer": { + "title": "Integrierter Browser", + "description": "Eine aus der Unterhaltung geöffnete Webseite", + "openInWorkspace": "Im Arbeitsbereich öffnen", + "cannotOpen": "Diese Adresse kann hier nicht geöffnet werden." + } } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 90f4db8c68..4038688cc1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "Delete", "canvasActions": "Canvas actions", "mergeIntoNewRegion": "New region" + }, + "Browser": { + "toolbar": { + "back": "Back", + "forward": "Forward", + "reload": "Reload", + "stop": "Stop", + "openInSystem": "Open in system browser", + "copyUrl": "Copy link", + "copied": "Link copied", + "addressPlaceholder": "Enter an address", + "invalidUrl": "Not a valid address" + }, + "status": { + "popupDenied": "Pop-up blocked: {host}", + "popupDeniedNoGesture": "opened without a click", + "popupDeniedBlockedScheme": "address type not allowed", + "dismiss": "Dismiss", + "errorDns": "This site can't be found", + "errorTls": "This connection is not secure", + "errorBlocked": "This address is blocked in the built-in browser", + "errorPopupDenied": "Pop-up blocked", + "errorFailed": "This page can't be loaded", + "retry": "Retry", + "openInSystem": "Open in system browser", + "ownedWindow": "This page is shown in its own window.", + "ownedWindowShow": "Show window", + "remoteBanner": "Opened through {host}" + }, + "tab": { + "untitled": "New tab" + }, + "drawer": { + "title": "Built-in browser", + "description": "A web page opened from the conversation", + "openInWorkspace": "Open in workspace", + "cannotOpen": "This address can't be opened here." + } } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2bce52a8bf..7764a2417b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "Eliminar", "canvasActions": "Acciones del lienzo", "mergeIntoNewRegion": "Nueva región" + }, + "Browser": { + "toolbar": { + "back": "Atrás", + "forward": "Adelante", + "reload": "Recargar", + "stop": "Detener", + "openInSystem": "Abrir en el navegador del sistema", + "copyUrl": "Copiar enlace", + "copied": "Enlace copiado", + "addressPlaceholder": "Escribe una dirección", + "invalidUrl": "No es una dirección válida" + }, + "status": { + "popupDenied": "Ventana emergente bloqueada: {host}", + "popupDeniedNoGesture": "no se abrió con un clic", + "popupDeniedBlockedScheme": "tipo de dirección no permitido", + "dismiss": "Cerrar", + "errorDns": "No se encuentra este sitio", + "errorTls": "Esta conexión no es segura", + "errorBlocked": "Esta dirección está bloqueada en el navegador integrado", + "errorPopupDenied": "Ventana emergente bloqueada", + "errorFailed": "No se puede cargar esta página", + "retry": "Reintentar", + "openInSystem": "Abrir en el navegador del sistema", + "ownedWindow": "Esta página se muestra en su propia ventana.", + "ownedWindowShow": "Mostrar ventana", + "remoteBanner": "Abierto a través de {host}" + }, + "tab": { + "untitled": "Nueva pestaña" + }, + "drawer": { + "title": "Navegador integrado", + "description": "Una página web abierta desde la conversación", + "openInWorkspace": "Abrir en el espacio de trabajo", + "cannotOpen": "Esta dirección no se puede abrir aquí." + } } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index adcf62f0dc..baf2954971 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "Supprimer", "canvasActions": "Actions du canevas", "mergeIntoNewRegion": "Nouvelle région" + }, + "Browser": { + "toolbar": { + "back": "Précédent", + "forward": "Suivant", + "reload": "Recharger", + "stop": "Arrêter", + "openInSystem": "Ouvrir dans le navigateur système", + "copyUrl": "Copier le lien", + "copied": "Lien copié", + "addressPlaceholder": "Saisir une adresse", + "invalidUrl": "Adresse non valide" + }, + "status": { + "popupDenied": "Fenêtre contextuelle bloquée : {host}", + "popupDeniedNoGesture": "ouverte sans clic", + "popupDeniedBlockedScheme": "type d'adresse non autorisé", + "dismiss": "Fermer", + "errorDns": "Ce site est introuvable", + "errorTls": "Cette connexion n'est pas sécurisée", + "errorBlocked": "Cette adresse est bloquée dans le navigateur intégré", + "errorPopupDenied": "Fenêtre contextuelle bloquée", + "errorFailed": "Impossible de charger cette page", + "retry": "Réessayer", + "openInSystem": "Ouvrir dans le navigateur système", + "ownedWindow": "Cette page s'affiche dans sa propre fenêtre.", + "ownedWindowShow": "Afficher la fenêtre", + "remoteBanner": "Ouvert via {host}" + }, + "tab": { + "untitled": "Nouvel onglet" + }, + "drawer": { + "title": "Navigateur intégré", + "description": "Une page web ouverte depuis la conversation", + "openInWorkspace": "Ouvrir dans l'espace de travail", + "cannotOpen": "Cette adresse ne peut pas être ouverte ici." + } } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c2cf389d89..c3836fe095 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "削除", "canvasActions": "キャンバス操作", "mergeIntoNewRegion": "新しい領域" + }, + "Browser": { + "toolbar": { + "back": "戻る", + "forward": "進む", + "reload": "再読み込み", + "stop": "停止", + "openInSystem": "システムのブラウザで開く", + "copyUrl": "リンクをコピー", + "copied": "リンクをコピーしました", + "addressPlaceholder": "アドレスを入力", + "invalidUrl": "有効なアドレスではありません" + }, + "status": { + "popupDenied": "ポップアップをブロックしました: {host}", + "popupDeniedNoGesture": "クリックによる操作ではありません", + "popupDeniedBlockedScheme": "許可されていないアドレス形式です", + "dismiss": "閉じる", + "errorDns": "このサイトは見つかりません", + "errorTls": "この接続は安全ではありません", + "errorBlocked": "内蔵ブラウザではこのアドレスを開けません", + "errorPopupDenied": "ポップアップがブロックされました", + "errorFailed": "ページを読み込めません", + "retry": "再試行", + "openInSystem": "システムのブラウザで開く", + "ownedWindow": "このページは別ウィンドウに表示されています。", + "ownedWindowShow": "ウィンドウを表示", + "remoteBanner": "{host} 経由で開いています" + }, + "tab": { + "untitled": "新しいタブ" + }, + "drawer": { + "title": "内蔵ブラウザ", + "description": "会話から開いたウェブページ", + "openInWorkspace": "ワークスペースで開く", + "cannotOpen": "このアドレスはここでは開けません。" + } } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 32f288ec3a..50c00f4342 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "삭제", "canvasActions": "캔버스 작업", "mergeIntoNewRegion": "새 영역" + }, + "Browser": { + "toolbar": { + "back": "뒤로", + "forward": "앞으로", + "reload": "새로 고침", + "stop": "중지", + "openInSystem": "시스템 브라우저에서 열기", + "copyUrl": "링크 복사", + "copied": "링크를 복사했습니다", + "addressPlaceholder": "주소 입력", + "invalidUrl": "올바른 주소가 아닙니다" + }, + "status": { + "popupDenied": "팝업 차단됨: {host}", + "popupDeniedNoGesture": "클릭으로 열린 창이 아닙니다", + "popupDeniedBlockedScheme": "허용되지 않는 주소 형식입니다", + "dismiss": "닫기", + "errorDns": "사이트를 찾을 수 없습니다", + "errorTls": "안전하지 않은 연결입니다", + "errorBlocked": "내장 브라우저에서 열 수 없는 주소입니다", + "errorPopupDenied": "팝업이 차단되었습니다", + "errorFailed": "페이지를 불러올 수 없습니다", + "retry": "다시 시도", + "openInSystem": "시스템 브라우저에서 열기", + "ownedWindow": "이 페이지는 별도의 창에 표시됩니다.", + "ownedWindowShow": "창 표시", + "remoteBanner": "{host}을(를) 통해 열림" + }, + "tab": { + "untitled": "새 탭" + }, + "drawer": { + "title": "내장 브라우저", + "description": "대화에서 연 웹 페이지", + "openInWorkspace": "작업 공간에서 열기", + "cannotOpen": "이 주소는 여기에서 열 수 없습니다." + } } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 071e5867b3..f5135960c7 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "Excluir", "canvasActions": "Ações do quadro", "mergeIntoNewRegion": "Nova região" + }, + "Browser": { + "toolbar": { + "back": "Voltar", + "forward": "Avançar", + "reload": "Recarregar", + "stop": "Parar", + "openInSystem": "Abrir no navegador do sistema", + "copyUrl": "Copiar link", + "copied": "Link copiado", + "addressPlaceholder": "Digite um endereço", + "invalidUrl": "Endereço inválido" + }, + "status": { + "popupDenied": "Pop-up bloqueado: {host}", + "popupDeniedNoGesture": "não foi aberto por um clique", + "popupDeniedBlockedScheme": "tipo de endereço não permitido", + "dismiss": "Fechar", + "errorDns": "Este site não foi encontrado", + "errorTls": "Esta conexão não é segura", + "errorBlocked": "Este endereço está bloqueado no navegador integrado", + "errorPopupDenied": "Pop-up bloqueado", + "errorFailed": "Não foi possível carregar esta página", + "retry": "Tentar novamente", + "openInSystem": "Abrir no navegador do sistema", + "ownedWindow": "Esta página é exibida em uma janela própria.", + "ownedWindowShow": "Mostrar janela", + "remoteBanner": "Aberto via {host}" + }, + "tab": { + "untitled": "Nova aba" + }, + "drawer": { + "title": "Navegador integrado", + "description": "Uma página da web aberta a partir da conversa", + "openInWorkspace": "Abrir no espaço de trabalho", + "cannotOpen": "Este endereço não pode ser aberto aqui." + } } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b2fccaed4c..b764539ae2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "删除", "canvasActions": "画布操作", "mergeIntoNewRegion": "新建区域" + }, + "Browser": { + "toolbar": { + "back": "后退", + "forward": "前进", + "reload": "刷新", + "stop": "停止", + "openInSystem": "在系统浏览器中打开", + "copyUrl": "复制链接", + "copied": "链接已复制", + "addressPlaceholder": "输入地址", + "invalidUrl": "不是有效的地址" + }, + "status": { + "popupDenied": "已拦截弹窗:{host}", + "popupDeniedNoGesture": "不是由点击触发", + "popupDeniedBlockedScheme": "不允许的地址类型", + "dismiss": "关闭", + "errorDns": "找不到该网站", + "errorTls": "连接不安全", + "errorBlocked": "内置浏览器不允许打开该地址", + "errorPopupDenied": "弹窗已拦截", + "errorFailed": "页面无法加载", + "retry": "重试", + "openInSystem": "在系统浏览器中打开", + "ownedWindow": "该页面显示在独立窗口中。", + "ownedWindowShow": "显示窗口", + "remoteBanner": "经由 {host} 打开" + }, + "tab": { + "untitled": "新标签页" + }, + "drawer": { + "title": "内置浏览器", + "description": "从会话中打开的网页", + "openInWorkspace": "在工作区中打开", + "cannotOpen": "无法在此打开该地址。" + } } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f5e8ce93d3..23cfe1523a 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5759,5 +5759,43 @@ "confirmDeleteConfirm": "刪除", "canvasActions": "畫布操作", "mergeIntoNewRegion": "新增區域" + }, + "Browser": { + "toolbar": { + "back": "上一頁", + "forward": "下一頁", + "reload": "重新整理", + "stop": "停止", + "openInSystem": "在系統瀏覽器中開啟", + "copyUrl": "複製連結", + "copied": "已複製連結", + "addressPlaceholder": "輸入網址", + "invalidUrl": "不是有效的網址" + }, + "status": { + "popupDenied": "已攔截彈出視窗:{host}", + "popupDeniedNoGesture": "並非由點擊觸發", + "popupDeniedBlockedScheme": "不允許的網址類型", + "dismiss": "關閉", + "errorDns": "找不到此網站", + "errorTls": "連線不安全", + "errorBlocked": "內建瀏覽器不允許開啟此網址", + "errorPopupDenied": "彈出視窗已攔截", + "errorFailed": "無法載入頁面", + "retry": "重試", + "openInSystem": "在系統瀏覽器中開啟", + "ownedWindow": "此頁面顯示在獨立視窗中。", + "ownedWindowShow": "顯示視窗", + "remoteBanner": "經由 {host} 開啟" + }, + "tab": { + "untitled": "新分頁" + }, + "drawer": { + "title": "內建瀏覽器", + "description": "從對話中開啟的網頁", + "openInWorkspace": "在工作區中開啟", + "cannotOpen": "無法在此開啟此網址。" + } } } diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 19564febf6..515dce1d9f 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -43,7 +43,8 @@ export function setBrowserTabState(state: BrowserTabState): void { } export function removeBrowserTabState(workspaceTabId: string): void { - if (states.delete(workspaceTabId)) notify() + const hadNotice = notices.delete(workspaceTabId) + if (states.delete(workspaceTabId) || hadNotice) notify() } export function subscribeBrowserTabs(listener: Listener): () => void { @@ -84,8 +85,37 @@ export function releaseBrowserTab(workspaceTabId: string): void { } } +/** A transient, dismissible message shown between the toolbar and the page. */ +export interface BrowserTabNotice { + kind: "popup-denied" + url: string + reason: string | null +} + +const notices = new Map<string, BrowserTabNotice>() + +export function setBrowserTabNotice( + workspaceTabId: string, + notice: BrowserTabNotice | null +): void { + if (notice) notices.set(workspaceTabId, notice) + else if (!notices.delete(workspaceTabId)) return + notify() +} + +export function useBrowserTabNotice( + workspaceTabId: string | null +): BrowserTabNotice | null { + return useSyncExternalStore( + subscribeBrowserTabs, + () => (workspaceTabId ? (notices.get(workspaceTabId) ?? null) : null), + getServerSnapshot + ) +} + export function resetBrowserTabStoreForTests(): void { states.clear() + notices.clear() listeners.clear() } diff --git a/src/lib/browser/native-surface-occlusion.test.ts b/src/lib/browser/native-surface-occlusion.test.ts new file mode 100644 index 0000000000..47f66a41e8 --- /dev/null +++ b/src/lib/browser/native-surface-occlusion.test.ts @@ -0,0 +1,75 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + acquireNativeSurfaceOcclusion, + isNativeSurfaceOccluded, + nativeSurfaceOcclusionHolders, + resetNativeSurfaceOcclusionForTests, + subscribeNativeSurfaceOcclusion, + useFallbackOverlayOpen, + useNativeSurfaceOccluded, + useNativeSurfaceOcclusion, +} from "./native-surface-occlusion" + +describe("native surface occlusion leases", () => { + beforeEach(() => resetNativeSurfaceOcclusionForTests()) + afterEach(() => { + resetNativeSurfaceOcclusionForTests() + document.body.innerHTML = "" + }) + + it("counts holders and notifies only on the 0↔1 edges", () => { + const listener = vi.fn() + subscribeNativeSurfaceOcclusion(listener) + const releaseA = acquireNativeSurfaceOcclusion("dialog") + const releaseB = acquireNativeSurfaceOcclusion("dialog") + const releaseC = acquireNativeSurfaceOcclusion("menu") + expect(isNativeSurfaceOccluded()).toBe(true) + expect(nativeSurfaceOcclusionHolders()).toEqual({ dialog: 2, menu: 1 }) + expect(listener).toHaveBeenCalledTimes(1) + releaseA() + releaseA() // double release is a no-op + expect(isNativeSurfaceOccluded()).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + releaseB() + releaseC() + expect(isNativeSurfaceOccluded()).toBe(false) + expect(nativeSurfaceOcclusionHolders()).toEqual({}) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it("useNativeSurfaceOcclusion holds a lease while mounted and active", () => { + const { result } = renderHook(() => useNativeSurfaceOccluded()) + const holder = renderHook( + ({ active }: { active: boolean }) => + useNativeSurfaceOcclusion("drawer", active), + { initialProps: { active: true } } + ) + expect(result.current).toBe(true) + act(() => holder.rerender({ active: false })) + expect(result.current).toBe(false) + act(() => holder.rerender({ active: true })) + expect(result.current).toBe(true) + act(() => holder.unmount()) + expect(result.current).toBe(false) + }) + + it("the fallback detector sees lease-less open dialogs and menus", async () => { + const { result } = renderHook(() => useFallbackOverlayOpen()) + expect(result.current).toBe(false) + const dialog = document.createElement("div") + dialog.setAttribute("role", "dialog") + dialog.setAttribute("data-state", "open") + await act(async () => { + document.body.appendChild(dialog) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(result.current).toBe(true) + await act(async () => { + dialog.setAttribute("data-state", "closed") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(result.current).toBe(false) + }) +}) diff --git a/src/lib/browser/native-surface-occlusion.ts b/src/lib/browser/native-surface-occlusion.ts new file mode 100644 index 0000000000..117e855f53 --- /dev/null +++ b/src/lib/browser/native-surface-occlusion.ts @@ -0,0 +1,146 @@ +// Occlusion leases for native browser surfaces. +// +// A child webview is a native view: it paints above every DOM element of the +// workspace window, so a dialog, command palette, context menu or drawer that +// opens over a browser tab would be hidden behind the page. Overlays therefore +// hold a lease while they are open; while any lease exists the surface hosts +// hide their webviews (handing keyboard focus back to the main webview first, +// so Esc / Tab reach the overlay) and show them again when the last lease is +// released. Coarse on purpose — any overlay hides every surface — because the +// rect-intersection refinement buys little and costs a layout read per +// overlay open. +// +// Zero cost when no browser tab is showing: the store has no subscribers and +// acquire/release is a counter bump. + +import { useEffect, useSyncExternalStore } from "react" + +const holders = new Map<string, number>() +const listeners = new Set<() => void>() +let count = 0 + +function notify(): void { + for (const listener of [...listeners]) listener() +} + +/** Take a lease; the returned function releases it exactly once. */ +export function acquireNativeSurfaceOcclusion(reason: string): () => void { + holders.set(reason, (holders.get(reason) ?? 0) + 1) + count += 1 + if (count === 1) notify() + let released = false + return () => { + if (released) return + released = true + const remaining = (holders.get(reason) ?? 1) - 1 + if (remaining <= 0) holders.delete(reason) + else holders.set(reason, remaining) + count = Math.max(0, count - 1) + if (count === 0) notify() + } +} + +export function isNativeSurfaceOccluded(): boolean { + return count > 0 +} + +/** Diagnostic: who is holding leases right now. */ +export function nativeSurfaceOcclusionHolders(): Record<string, number> { + return Object.fromEntries(holders) +} + +export function subscribeNativeSurfaceOcclusion( + listener: () => void +): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getServerSnapshot(): boolean { + return false +} + +/** True while any overlay holds a lease. */ +export function useNativeSurfaceOccluded(): boolean { + return useSyncExternalStore( + subscribeNativeSurfaceOcclusion, + isNativeSurfaceOccluded, + getServerSnapshot + ) +} + +/** + * For overlay components: hold a lease for as long as the component is + * mounted (Radix content mounts only while open) and `active` is true. + */ +export function useNativeSurfaceOcclusion(reason: string, active = true): void { + useEffect(() => { + if (!active) return + return acquireNativeSurfaceOcclusion(reason) + }, [reason, active]) +} + +export function resetNativeSurfaceOcclusionForTests(): void { + holders.clear() + listeners.clear() + count = 0 +} + +// --------------------------------------------------------------------------- +// Fallback detector for overlays that do not hold a lease (third-party +// portals, components that predate the lease). Watches the document for open +// dialog / menu roles. Only consulted when no lease is held, and only +// observing while some surface host is mounted. + +const FALLBACK_SELECTOR = + '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"]' + +const fallbackListeners = new Set<() => void>() +let fallbackObserver: MutationObserver | null = null +let fallbackOpen = false + +function evaluateFallback(): void { + const next = + typeof document !== "undefined" && + document.querySelector(FALLBACK_SELECTOR) !== null + if (next === fallbackOpen) return + fallbackOpen = next + for (const listener of [...fallbackListeners]) listener() +} + +function subscribeFallback(listener: () => void): () => void { + fallbackListeners.add(listener) + if ( + !fallbackObserver && + typeof MutationObserver !== "undefined" && + typeof document !== "undefined" + ) { + fallbackObserver = new MutationObserver(evaluateFallback) + fallbackObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-state"], + }) + evaluateFallback() + } + return () => { + fallbackListeners.delete(listener) + if (fallbackListeners.size === 0 && fallbackObserver) { + fallbackObserver.disconnect() + fallbackObserver = null + fallbackOpen = false + } + } +} + +/** True while an open dialog / menu is in the document (lease-less path). */ +export function useFallbackOverlayOpen(): boolean { + return useSyncExternalStore( + subscribeFallback, + () => fallbackOpen, + getServerSnapshot + ) +} diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index 1d06299b1e..d622940e0f 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -86,6 +86,16 @@ export interface BrowserTelemetryPayload { payload: unknown } +/** Backend → frontend: open this URL as a browser tab (agent tools, deep + * links, the dev puppet). The frontend owns the tab records. */ +export interface BrowserOpenRequestPayload { + url: string + source: string + activate: boolean + ownerWindow: string | null +} + +export const BROWSER_OPEN_REQUEST_EVENT = "browser://open-request" export const BROWSER_STATE_EVENT = "browser://state" export const BROWSER_CLOSED_EVENT = "browser://closed" export const BROWSER_POPUP_EVENT = "browser://popup" diff --git a/src/lib/browser/window-label.ts b/src/lib/browser/window-label.ts new file mode 100644 index 0000000000..c5eff597e9 --- /dev/null +++ b/src/lib/browser/window-label.ts @@ -0,0 +1,16 @@ +// Label of the Tauri window this document runs in. Read from the injected +// Tauri metadata (no IPC, no permission needed); "main" outside the desktop +// runtime and in tests. + +declare global { + interface Window { + __TAURI_INTERNALS__?: { + metadata?: { currentWindow?: { label?: string } } + } + } +} + +export function getCurrentWindowLabel(): string { + if (typeof window === "undefined") return "main" + return window.__TAURI_INTERNALS__?.metadata?.currentWindow?.label ?? "main" +} From b6425e2420a29e3a26fe8606a78d33d21d1f260b Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 16:47:10 +0800 Subject: [PATCH 07/79] fix(browser): bind occlusion leases to overlay DOM and sweep orphaned surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Dialog / AlertDialog / DropdownMenu / ContextMenu / Drawer content wrappers stay mounted while their overlay is closed — only the primitive inside unmounts its DOM — so a lease taken on wrapper mount was held by every closed overlay in the tree (30 leases with nothing open) and every browser surface stayed hidden. The lease now lives on the content element's callback ref: acquired when the node attaches, released when it detaches, which is exactly when the overlay is really open. Verified against a live dropdown: opening it hides the active tab's webview, Escape brings it back. BrowserEventsBridge closes any surface the backend still holds when it first mounts: tab records are session-only, so after a document reload those webviews would otherwise stay painted with nothing left to hide them. The dev puppet gains a browser_debug op exposing the native hidden flag and frame next to the registry's view. --- src-tauri/src/browser/shim/macos.rs | 23 +++++++ src-tauri/src/browser/smoke.rs | 11 ++++ src-tauri/src/browser/surface.rs | 7 ++ src-tauri/src/browser/surface_child.rs | 19 ++++++ .../browser/browser-events-bridge.test.tsx | 13 +++- .../browser/browser-events-bridge.tsx | 17 ++++- .../browser/browser-surface-host.tsx | 1 - src/components/ui/alert-dialog.tsx | 20 +++++- src/components/ui/context-menu.tsx | 20 +++++- src/components/ui/dialog.tsx | 20 ++++-- src/components/ui/drawer.tsx | 14 ++-- src/components/ui/dropdown-menu.tsx | 20 +++++- .../browser/native-surface-occlusion.test.ts | 28 ++++++++ src/lib/browser/native-surface-occlusion.ts | 66 ++++++++++++++++++- 14 files changed, 255 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 8f4027c799..9d141b4807 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -262,3 +262,26 @@ pub fn stop_loading(webview: &wry::WebView) { pub fn webview_pointer(webview: &wry::WebView) -> usize { Retained::as_ptr(&webview.webview()) as usize } + +/// Diagnostic view of the native state (dev puppet only). +pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { + let wk = webview.webview(); + // SAFETY: main thread, live view. + let (hidden, hidden_or_ancestor, has_window, has_superview, frame) = unsafe { + let frame = wk.frame(); + ( + wk.isHidden(), + wk.isHiddenOrHasHiddenAncestor(), + wk.window().is_some(), + wk.superview().is_some(), + [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height], + ) + }; + serde_json::json!({ + "hidden": hidden, + "hiddenOrAncestor": hidden_or_ancestor, + "hasWindow": has_window, + "hasSuperview": has_superview, + "frame": frame, + }) +} diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index a793ff3628..1d7ea2a3d7 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -391,6 +391,17 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .map_err(|_| "main eval timed out".to_string())?; Ok(serde_json::from_str(&value).unwrap_or(Value::String(value))) } + "browser_debug" => { + let tab_id = str_arg(cmd, "tab_id")?; + let surface = registry.surface(&tab_id).ok_or("no such tab")?; + let (visible, bounds) = registry + .update(&tab_id, |t| (t.visible, t.last_bounds)) + .ok_or("no such tab")?; + Ok(json!({ + "registry": { "visible": visible, "lastBounds": bounds }, + "native": surface.debug_view().map_err(err_string)?, + })) + } "browser_focus" => { let surface = registry .surface(&str_arg(cmd, "tab_id")?) diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index 4a9bbb4d41..5884d01d52 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -179,6 +179,13 @@ impl BrowserSurface { window: |_w| Err(SurfaceError("stop for owned windows lands with the platform shims".into()))) } + /// Dev puppet only. + pub fn debug_view(&self) -> Result<serde_json::Value, SurfaceError> { + per_surface!(self, + child: |c| Ok(c.debug_view()?), + window: |w| Ok(serde_json::json!({ "visible": w.is_visible().ok() }))) + } + pub fn set_zoom(&self, factor: f64) -> Result<(), SurfaceError> { per_surface!(self, child: |c| Ok(c.zoom(factor)?), window: |w| Ok(w.set_zoom(factor)?)) } diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index f9b4de5d57..68c4a7a0af 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -324,6 +324,25 @@ impl ChildHandle { } } + /// Dev puppet only: native-side state for a tab. + pub fn debug_view(&self) -> Result<serde_json::Value, ChildError> { + #[cfg(target_os = "macos")] + { + self.with(|wv| { + let mut v = shim::debug_view(wv); + v["wryBounds"] = wv + .bounds() + .map(|b| serde_json::json!(format!("{b:?}"))) + .unwrap_or(serde_json::Value::Null); + v + }) + } + #[cfg(not(target_os = "macos"))] + { + Ok(serde_json::Value::Null) + } + } + /// Detach and drop the webview (on the main thread; wry removes the /// native view from the window when the `WebView` drops). pub fn close(&self) -> Result<(), ChildError> { diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index c0951fc755..134186075d 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -31,12 +31,17 @@ const mocks = vi.hoisted(() => { adoptBrowserTab: vi.fn(() => "browser:opener-p1"), closeFileTab: vi.fn(), openBrowserTab: vi.fn(() => "browser:new"), + browserClose: vi.fn(() => Promise.resolve()), + browserListTabs: vi.fn(() => + Promise.resolve([{ tabId: "stale-1" }, { tabId: "stale-2" }]) + ), } }) vi.mock("@/lib/browser/browser-api", () => ({ browserCapabilities: mocks.capabilities, - browserClose: vi.fn(() => Promise.resolve()), + browserClose: mocks.browserClose, + browserListTabs: mocks.browserListTabs, })) vi.mock("@/lib/transport", () => ({ getTransport: () => ({ subscribe: mocks.subscribe }), @@ -72,13 +77,17 @@ describe("BrowserEventsBridge", () => { mocks.adoptBrowserTab.mockClear() mocks.closeFileTab.mockClear() mocks.openBrowserTab.mockClear() + mocks.browserClose.mockClear() resetBrowserTabStoreForTests() }) afterEach(() => resetBrowserTabStoreForTests()) - it("subscribes to the three streams once the capabilities say a browser exists", async () => { + it("subscribes to the streams once the capabilities say a browser exists, after sweeping orphans", async () => { const { unmount } = render(<BrowserEventsBridge />) await flush() + // Surfaces left over from a previous document are closed first. + expect(mocks.browserClose).toHaveBeenCalledWith("stale-1") + expect(mocks.browserClose).toHaveBeenCalledWith("stale-2") expect([...mocks.handlers.keys()].sort()).toEqual([ "browser://closed", "browser://open-request", diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 12ebe8ae56..24dfa4fb8c 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -3,7 +3,11 @@ import { useEffect } from "react" import { useWorkspaceActions } from "@/contexts/workspace-context" -import { browserCapabilities } from "@/lib/browser/browser-api" +import { + browserCapabilities, + browserClose, + browserListTabs, +} from "@/lib/browser/browser-api" import { browserWorkspaceTabId, removeBrowserTabState, @@ -49,6 +53,17 @@ export function BrowserEventsBridge() { void (async () => { const capabilities = await browserCapabilities() if (cancelled || !capabilities.available) return + // Tab records are session-only, so any surface the backend still holds + // for this window when the bridge first mounts is an orphan of a + // previous document (a dev reload, a crashed frontend). Close them, + // or they would stay painted over the new UI with nothing to hide them. + try { + const orphans = await browserListTabs() + await Promise.all(orphans.map((tab) => browserClose(tab.tabId))) + } catch { + /* nothing to sweep */ + } + if (cancelled) return const transport = getTransport() const subs = await Promise.all([ transport.subscribe<BrowserTabState>(BROWSER_STATE_EVENT, (state) => { diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index 6ada680981..ed58d538cb 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -192,7 +192,6 @@ export function BrowserSurfaceHost({ <div ref={ref} data-browser-surface={backendId ?? undefined} - data-browser-visible={lastVisibleRef.current ? "true" : "false"} className={cn( "relative h-full w-full min-h-0 min-w-0 bg-background", className diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 6f4a4c86b6..d40dba3b05 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -5,7 +5,8 @@ import { AlertDialog as AlertDialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" +import { acquireNativeSurfaceOcclusionFor } from "@/lib/browser/native-surface-occlusion" +import { attachRef } from "@/lib/attach-ref" function AlertDialog({ ...props @@ -48,17 +49,30 @@ function AlertDialogOverlay({ function AlertDialogContent({ className, size = "default", + ref, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & { size?: "default" | "sm" }) { - // A native browser surface would paint over this overlay; hide it while open. - useNativeSurfaceOcclusion("alert-dialog") + // Occlusion lease for the built-in browser, held while the content's DOM + // exists (see dialog.tsx). + const contentRef = React.useCallback( + (node: HTMLDivElement | null) => { + const detach = attachRef(ref, node) + const release = acquireNativeSurfaceOcclusionFor("alert-dialog", true) + return () => { + release() + detach() + } + }, + [ref] + ) return ( <AlertDialogPortal> <AlertDialogOverlay /> <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center p-4"> <AlertDialogPrimitive.Content + ref={contentRef} data-slot="alert-dialog-content" data-size={size} className={cn( diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 18056f3bc3..a38cedcb05 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -4,7 +4,8 @@ import * as React from "react" import { ContextMenu as ContextMenuPrimitive } from "radix-ui" import { cn } from "@/lib/utils" -import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" +import { acquireNativeSurfaceOcclusionFor } from "@/lib/browser/native-surface-occlusion" +import { attachRef } from "@/lib/attach-ref" function ContextMenu({ ...props @@ -22,13 +23,26 @@ function ContextMenuTrigger({ function ContextMenuContent({ className, + ref, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) { - // A native browser surface would paint over this overlay; hide it while open. - useNativeSurfaceOcclusion("context-menu") + // Occlusion lease for the built-in browser, held while the menu's DOM + // exists (see dialog.tsx). + const contentRef = React.useCallback( + (node: HTMLDivElement | null) => { + const detach = attachRef(ref, node) + const release = acquireNativeSurfaceOcclusionFor("context-menu", true) + return () => { + release() + detach() + } + }, + [ref] + ) return ( <ContextMenuPrimitive.Portal> <ContextMenuPrimitive.Content + ref={contentRef} data-slot="context-menu-content" className={cn( "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 z-50 overflow-hidden", diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 01f5e973d3..0c5af4d428 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button" import { OverlayPortalContainerProvider } from "@/components/ui/overlay-portal-container" import { useNestedLayerDismissGuard } from "@/hooks/use-nested-layer-dismiss-guard" import { cn } from "@/lib/utils" -import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" +import { acquireNativeSurfaceOcclusionFor } from "@/lib/browser/native-surface-occlusion" function Dialog({ ...props @@ -62,8 +62,6 @@ function DialogContent({ closeButtonClassName?: string showCloseButton?: boolean }) { - // A native browser surface would paint over this overlay; hide it while open. - useNativeSurfaceOcclusion("dialog") // Without this, closing a nested Select/DropdownMenu by clicking elsewhere in // the dialog closes the dialog too. const { @@ -71,13 +69,27 @@ function DialogContent({ setNode, onPointerDownOutside: guardOutsidePress, } = useNestedLayerDismissGuard<HTMLDivElement>(ref) + // A native browser surface would paint over this overlay, so it holds an + // occlusion lease exactly while its DOM exists (this component stays mounted + // with the dialog closed; only the primitive's content comes and goes). + const contentRef = React.useCallback( + (contentNode: HTMLDivElement | null) => { + const detach = setNode(contentNode) + const release = acquireNativeSurfaceOcclusionFor("dialog", true) + return () => { + release() + if (typeof detach === "function") detach() + } + }, + [setNode] + ) return ( <DialogPortal> <DialogOverlay /> <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center p-4"> <DialogPrimitive.Content data-slot="dialog-content" - ref={setNode} + ref={contentRef} onPointerDownOutside={(event) => { onPointerDownOutside?.(event) guardOutsidePress(event) diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index a8613d278b..2f008d94ac 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -9,7 +9,7 @@ import { attachRef } from "@/lib/attach-ref" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" -import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" +import { acquireNativeSurfaceOcclusionFor } from "@/lib/browser/native-surface-occlusion" type DrawerContextProps = { hasSnapPoints: boolean @@ -275,8 +275,6 @@ function DrawerContent({ * browser viewer): it must not take the lease that would hide it. */ nativeSurfaceHost?: boolean }) { - // A native browser surface would paint over this overlay; hide it while open. - useNativeSurfaceOcclusion("drawer", !nativeSurfaceHost) const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer() const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x" @@ -294,12 +292,20 @@ function DrawerContent({ (node: HTMLDivElement | null) => { setPopup(node) const detach = attachRef(ref, node) + // A native browser surface would paint over this drawer, so hold an + // occlusion lease while the popup's DOM exists — unless this drawer is + // the one hosting such a surface (the transcript's browser viewer). + const release = acquireNativeSurfaceOcclusionFor( + "drawer", + !nativeSurfaceHost + ) return () => { + release() setPopup(null) detach() } }, - [ref] + [ref, nativeSurfaceHost] ) return ( diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 7453422cc0..fef82e3808 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -5,7 +5,8 @@ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { CheckIcon, ChevronRightIcon } from "lucide-react" -import { useNativeSurfaceOcclusion } from "@/lib/browser/native-surface-occlusion" +import { acquireNativeSurfaceOcclusionFor } from "@/lib/browser/native-surface-occlusion" +import { attachRef } from "@/lib/attach-ref" function DropdownMenu({ ...props @@ -36,13 +37,26 @@ function DropdownMenuContent({ className, align = "start", sideOffset = 4, + ref, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { - // A native browser surface would paint over this overlay; hide it while open. - useNativeSurfaceOcclusion("dropdown-menu") + // Occlusion lease for the built-in browser, held while the menu's DOM + // exists (see dialog.tsx). + const contentRef = React.useCallback( + (node: HTMLDivElement | null) => { + const detach = attachRef(ref, node) + const release = acquireNativeSurfaceOcclusionFor("dropdown-menu", true) + return () => { + release() + detach() + } + }, + [ref] + ) return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content + ref={contentRef} data-slot="dropdown-menu-content" sideOffset={sideOffset} align={align} diff --git a/src/lib/browser/native-surface-occlusion.test.ts b/src/lib/browser/native-surface-occlusion.test.ts index 47f66a41e8..e8062e5d28 100644 --- a/src/lib/browser/native-surface-occlusion.test.ts +++ b/src/lib/browser/native-surface-occlusion.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { acquireNativeSurfaceOcclusion, + acquireNativeSurfaceOcclusionFor, isNativeSurfaceOccluded, nativeSurfaceOcclusionHolders, resetNativeSurfaceOcclusionForTests, @@ -10,6 +11,7 @@ import { useFallbackOverlayOpen, useNativeSurfaceOccluded, useNativeSurfaceOcclusion, + useNativeSurfaceOcclusionRef, } from "./native-surface-occlusion" describe("native surface occlusion leases", () => { @@ -73,3 +75,29 @@ describe("native surface occlusion leases", () => { expect(result.current).toBe(false) }) }) + +describe("DOM-bound leases", () => { + it("useNativeSurfaceOcclusionRef holds a lease only while attached to a node", () => { + resetNativeSurfaceOcclusionForTests() + const { result } = renderHook(() => useNativeSurfaceOcclusionRef("menu")) + expect(isNativeSurfaceOccluded()).toBe(false) + const node = document.createElement("div") + act(() => result.current(node)) + expect(isNativeSurfaceOccluded()).toBe(true) + act(() => result.current(node)) // re-attach is not a second lease + expect(nativeSurfaceOcclusionHolders()).toEqual({ menu: 1 }) + act(() => result.current(null)) + expect(isNativeSurfaceOccluded()).toBe(false) + }) + + it("acquireNativeSurfaceOcclusionFor is a no-op when inactive", () => { + resetNativeSurfaceOcclusionForTests() + const release = acquireNativeSurfaceOcclusionFor("drawer", false) + expect(isNativeSurfaceOccluded()).toBe(false) + release() + const release2 = acquireNativeSurfaceOcclusionFor("drawer", true) + expect(isNativeSurfaceOccluded()).toBe(true) + release2() + expect(isNativeSurfaceOccluded()).toBe(false) + }) +}) diff --git a/src/lib/browser/native-surface-occlusion.ts b/src/lib/browser/native-surface-occlusion.ts index 117e855f53..680487e90d 100644 --- a/src/lib/browser/native-surface-occlusion.ts +++ b/src/lib/browser/native-surface-occlusion.ts @@ -13,7 +13,7 @@ // Zero cost when no browser tab is showing: the store has no subscribers and // acquire/release is a counter bump. -import { useEffect, useSyncExternalStore } from "react" +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react" const holders = new Map<string, number>() const listeners = new Set<() => void>() @@ -72,8 +72,13 @@ export function useNativeSurfaceOccluded(): boolean { } /** - * For overlay components: hold a lease for as long as the component is - * mounted (Radix content mounts only while open) and `active` is true. + * For components that exist ONLY while their overlay is open: hold a lease + * for as long as the component is mounted and `active` is true. + * + * Not for the shared `*Content` wrappers: React keeps those function + * components mounted whenever their parent renders them, open or not — only + * the primitive inside unmounts its DOM on close. Those use + * `useNativeSurfaceOcclusionRef` and bind the lease to the DOM node instead. */ export function useNativeSurfaceOcclusion(reason: string, active = true): void { useEffect(() => { @@ -82,6 +87,51 @@ export function useNativeSurfaceOcclusion(reason: string, active = true): void { }, [reason, active]) } +/** + * A callback ref that holds a lease exactly while the element it is attached + * to is in the DOM — i.e. while the overlay is actually open (Radix and Base + * UI unmount closed content, keeping it only through the exit animation). + * Compose it with the wrapper's own ref; it returns nothing, so React calls + * it again with `null` on detach. + */ +export function useNativeSurfaceOcclusionRef( + reason: string, + active = true +): (node: Element | null) => void { + const releaseRef = useRef<(() => void) | null>(null) + // Release on unmount too, in case the element is never detached explicitly. + useEffect( + () => () => { + releaseRef.current?.() + releaseRef.current = null + }, + [] + ) + return useCallback( + (node: Element | null) => { + if (node && active) { + if (!releaseRef.current) { + releaseRef.current = acquireNativeSurfaceOcclusion(reason) + } + } else { + releaseRef.current?.() + releaseRef.current = null + } + }, + [reason, active] + ) +} + +/** Acquire a lease imperatively for the lifetime of a DOM node managed by a + * callback ref that returns its own cleanup (React 19 style). */ +export function acquireNativeSurfaceOcclusionFor( + reason: string, + active: boolean +): () => void { + if (!active) return () => {} + return acquireNativeSurfaceOcclusion(reason) +} + export function resetNativeSurfaceOcclusionForTests(): void { holders.clear() listeners.clear() @@ -144,3 +194,13 @@ export function useFallbackOverlayOpen(): boolean { getServerSnapshot ) } + +// Dev-only introspection for the puppet / devtools console. +if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") { + ;(window as unknown as Record<string, unknown>).__codegOcclusionDebug = + () => ({ + count, + holders: nativeSurfaceOcclusionHolders(), + fallbackOpen, + }) +} From 2bfd94dec87bf515270c59295d39bea99bed8760 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 16:54:26 +0800 Subject: [PATCH 08/79] feat(browser): route transcript and terminal links through the link decision useOpenUrlTarget decides and executes where an http(s) (or mailto/tel) address goes, inside the click's own call stack: resolveLinkAction over a preferences snapshot and the current surface (built-in browser available, file column visible, viewer host present, remote window), then a built-in tab, the transcript's side-panel browser under a full-page route, the system browser, or the OS handler. The first built-in open shows a one-time toast with an "always use the system browser" action. link-safety's http(s)/mailto/tel branch now goes through it (local file paths keep their existing route); the modifier state of the click rides along even though Streamdown's link-safety contract only passes the URL. Web links in the transcript gain a context menu: built-in, system, copy. The terminal's WebLinksAddon gets a real handler, which also fixes the dead link click in the desktop webview (xterm's default is a bare window.open). The open primitives move to lib/link-open.ts, and browserCapabilitiesSnapshot() gives synchronous access to the resolved capabilities; until they resolve, links go to the system browser. --- .../ai-elements/link-safety.test.tsx | 4 + src/components/ai-elements/link-safety.tsx | 114 +++++------ .../ai-elements/markdown-link.test.tsx | 8 + src/components/ai-elements/markdown-link.tsx | 94 +++++++-- .../ai-elements/message-codeg-badge.test.tsx | 8 + .../message-windows-file-link.test.tsx | 1 + .../ai-elements/message-windows-path.test.tsx | 1 + src/components/terminal/terminal-view.tsx | 20 +- src/contexts/workspace-context.tsx | 6 + src/hooks/use-open-url-target.test.tsx | 179 ++++++++++++++++++ src/hooks/use-open-url-target.ts | 119 ++++++++++++ src/i18n/messages/ar.json | 10 + src/i18n/messages/de.json | 10 + src/i18n/messages/en.json | 10 + src/i18n/messages/es.json | 10 + src/i18n/messages/fr.json | 10 + src/i18n/messages/ja.json | 10 + src/i18n/messages/ko.json | 10 + src/i18n/messages/pt.json | 10 + src/i18n/messages/zh-CN.json | 10 + src/i18n/messages/zh-TW.json | 10 + src/lib/browser/browser-api.ts | 28 ++- src/lib/link-open.test.ts | 58 ++++++ src/lib/link-open.ts | 86 +++++++++ 24 files changed, 732 insertions(+), 94 deletions(-) create mode 100644 src/hooks/use-open-url-target.test.tsx create mode 100644 src/hooks/use-open-url-target.ts create mode 100644 src/lib/link-open.test.ts create mode 100644 src/lib/link-open.ts diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index 89356f1510..1d01af3541 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -34,6 +34,9 @@ vi.mock("@/lib/platform", () => ({ vi.mock("@/lib/transport", () => ({ isDesktop: mocks.isDesktop, getActiveRemoteConnectionId: mocks.getActiveRemoteConnectionId, + // A desktop window bound to a remote server — mirrors the real helper. + isRemoteDesktopMode: () => + mocks.isDesktop() && mocks.getActiveRemoteConnectionId() !== null, })) vi.mock("@/contexts/active-folder-context", () => ({ @@ -45,6 +48,7 @@ vi.mock("@/contexts/active-folder-context", () => ({ })) vi.mock("@/contexts/workspace-context", () => ({ + useOptionalWorkspaceActions: () => null, useWorkspaceActions: () => ({ openFilePreview: mocks.openFilePreview, }), diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 55eb13a643..13d5be7e3c 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -3,9 +3,12 @@ import type { ReactNode } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslations } from "next-intl" -import { openUrl } from "@/lib/platform" -import { getActiveRemoteConnectionId, isDesktop } from "@/lib/transport" import { toErrorMessage } from "@/lib/app-error" +import { openExternalTab, windowOpenReachesABrowser } from "@/lib/link-open" +import { + isPrimaryModifier, + useOpenUrlTarget, +} from "@/hooks/use-open-url-target" import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" import { toast } from "sonner" import { useActiveFolder } from "@/contexts/active-folder-context" @@ -28,34 +31,6 @@ import { export { parseLocalFileTarget } export type { LocalFileTarget } -/** - * True when `window.open` actually opens something — i.e. a real browser. - * - * NOT the same question as `isWebOpenerEnvironment` below. A Tauri window bound - * to a remote codeg-server is still a TAURI WEBVIEW, and a webview that - * registers no new-window handler opens nothing at all for `window.open` (wry - * answers with nil on macOS, `SetHandled(true)` on Windows). Lumping remote - * windows in with web mode here left every http(s) link in a remote workspace - * silently dead; they must take the opener-plugin path instead, which - * `capabilities/default.json` grants to the `remote-*` windows. - */ -function windowOpenReachesABrowser(): boolean { - return !isDesktop() -} - -/** - * True when a `mailto:`/`tel:` URL should be handed to the OS through a - * synthetic anchor rather than the Tauri opener plugin — pure web, or a Tauri - * window bound to a remote codeg-server. - * - * The remote arm stays deliberately: unlike `window.open`, a synthetic anchor - * DOES reach the OS handler from inside a webview, and it sidesteps the - * question of whether the opener capability covers non-http(s) schemes. - */ -function isWebOpenerEnvironment(): boolean { - return !isDesktop() || getActiveRemoteConnectionId() !== null -} - function shouldLetStreamdownOpenExternalUrl(rawUrl: string): boolean { if (parseLocalFileTarget(rawUrl)) return false const protocol = getAllowedExternalProtocol(rawUrl) @@ -69,35 +44,30 @@ function shouldLetStreamdownOpenExternalUrl(rawUrl: string): boolean { return windowOpenReachesABrowser() } -/** - * Trigger an OS-registered protocol handler (mail client, dialer) from a - * browser without leaving an empty tab. The synthetic anchor has no - * `target`, so the browser hands the URL to the OS handler and stays on - * the current page. - */ -function dispatchOsHandlerUrl(url: string): void { - const anchor = document.createElement("a") - anchor.href = url - anchor.rel = "noreferrer noopener" - document.body.appendChild(anchor) - try { - anchor.click() - } finally { - anchor.remove() - } +// `openExternalTab` moved to `@/lib/link-open`; re-exported for the transcript +// components that import it from here. +export { openExternalTab } + +// The modifier state of the most recent link gesture. Streamdown's link-safety +// contract hands `useOpenLinkOrFile` only the URL (through its modal hook), so +// the click handler parks the gesture here and the opener reads it back within +// the same second. A stale record is ignored. +let recentLinkGesture: { modifier: boolean; at: number } | null = null +const LINK_GESTURE_WINDOW_MS = 1000 + +export function rememberLinkGesture(event: { + metaKey?: boolean + ctrlKey?: boolean +}): void { + recentLinkGesture = { modifier: isPrimaryModifier(event), at: Date.now() } } -/** - * Open an external URL in a new tab. Callers MUST invoke this inside the - * click's own call stack — see `openLinkWithSafety`. - */ -export function openExternalTab(url: string): void { - // `noreferrer` (which implies `noopener`) matters for AI-authored links: the - // opened page gets no `window.opener` handle back into the app and no - // Referer. It also makes `window.open` return null even on success (HTML - // window open steps 12 and 17), so the return value carries no signal — - // don't test it for a "popup blocked" check, it would fire on every success. - window.open(url, "_blank", "noreferrer") +function consumeLinkGestureModifier(): boolean { + const gesture = recentLinkGesture + recentLinkGesture = null + return gesture !== null && Date.now() - gesture.at <= LINK_GESTURE_WINDOW_MS + ? gesture.modifier + : false } /** @@ -115,8 +85,10 @@ export function openExternalTab(url: string): void { export function openLinkWithSafety( url: string, linkSafety: LinkSafetyConfig, - decline: () => void + decline: () => void, + gesture?: { metaKey?: boolean; ctrlKey?: boolean } ): void { + if (gesture) rememberLinkGesture(gesture) const verdict = linkSafety.onLinkCheck?.(url) if (verdict === true) { openExternalTab(url) @@ -204,6 +176,7 @@ export function useOpenLinkOrFile() { const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path const openFileTarget = useOpenFileTarget() + const openUrlTarget = useOpenUrlTarget() return useCallback( async (url: string) => { @@ -239,19 +212,18 @@ export function useOpenLinkOrFile() { return } - // Dispatch the CANONICAL form: a protocol-relative "//host/…" must - // reach the desktop opener as a concrete https URL — the opener - // capability only allows http(s), and raw "//…" would resolve - // against the webview's own scheme. - const openTarget = url.trim().startsWith("//") - ? `https:${url.trim()}` - : url - + // http(s) and mailto/tel: the link decision (built-in browser, system + // browser, OS handler) runs and executes synchronously; the canonical + // form of a protocol-relative "//host/…" is produced in there. try { - if (OS_HANDLER_PROTOCOLS.has(protocol) && isWebOpenerEnvironment()) { - dispatchOsHandlerUrl(openTarget) - } else { - await openUrl(openTarget) + const action = openUrlTarget(url, { + source: "transcript", + modifier: consumeLinkGestureModifier(), + }) + if (action.kind === "reject") { + toast.error(t("errorFailedLink"), { + description: t("errorUnsupportedLinkProtocol"), + }) } } catch (error) { toast.error(t("errorFailedLink"), { @@ -259,7 +231,7 @@ export function useOpenLinkOrFile() { }) } }, - [folderPath, openFileTarget, t] + [folderPath, openFileTarget, openUrlTarget, t] ) } diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index d183ae8345..dc2f83396f 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -9,6 +9,14 @@ const mocks = vi.hoisted(() => ({ ), })) +vi.mock("next-intl", () => { + const t = (key: string) => key + return { useTranslations: () => t } +}) +vi.mock("@/hooks/use-open-url-target", () => ({ + useOpenUrlTarget: () => () => ({ kind: "system", url: "" }), + isPrimaryModifier: () => false, +})) vi.mock("./link-safety", async (importOriginal) => { // Only the streamdown hook is stubbed — `parseLocalFileTarget` stays real, so // the file badge's hover-actions anchor wraps it exactly as it would in the app. diff --git a/src/components/ai-elements/markdown-link.tsx b/src/components/ai-elements/markdown-link.tsx index cb2957ed3e..5d100cce2c 100644 --- a/src/components/ai-elements/markdown-link.tsx +++ b/src/components/ai-elements/markdown-link.tsx @@ -17,6 +17,15 @@ import { parseLocalFileTarget, useStreamdownLinkSafety, } from "./link-safety" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { useOpenUrlTarget } from "@/hooks/use-open-url-target" +import { copyTextToClipboard } from "@/lib/utils" +import { useTranslations } from "next-intl" const RESOURCE_KIND_ICON: Record<ResourceKind, LucideIcon> = { file: FileText, @@ -67,6 +76,8 @@ export function MarkdownLink({ ...rest }: MarkdownLinkProps) { const linkSafety = useStreamdownLinkSafety() + const openUrlTarget = useOpenUrlTarget() + const tLink = useTranslations("Browser.link") const [modalOpen, setModalOpen] = useState(false) const isIncomplete = href === INCOMPLETE_LINK @@ -78,7 +89,10 @@ export function MarkdownLink({ (event: MouseEvent<HTMLButtonElement>) => { if (!href || isIncomplete) return event.preventDefault() - openLinkWithSafety(href, linkSafety, () => setModalOpen(true)) + // The gesture's modifier rides along: ⌘/Ctrl-click opens a web link + // "the other way" (system browser instead of the built-in one, or vice + // versa) — see `resolveLinkAction`. + openLinkWithSafety(href, linkSafety, () => setModalOpen(true), event) }, [href, isIncomplete, linkSafety] ) @@ -181,28 +195,66 @@ export function MarkdownLink({ ) } + const button = ( + <button + type="button" + data-incomplete={isIncomplete} + data-streamdown="link" + data-resource-kind={kind ?? undefined} + title={isIncomplete ? undefined : href} + onClick={handleClick} + className={cn( + "wrap-anywhere appearance-none text-left font-medium text-primary underline", + className + )} + > + {Icon ? ( + <Icon + aria-hidden="true" + className="mr-0.5 inline size-[1em] align-[-0.15em] opacity-80" + /> + ) : null} + {children} + </button> + ) + + // A web link gets an explicit-choice menu: built-in browser, system + // browser, copy. Explicit choices bypass the per-source preference (but not + // the terminal rules — a blocked host stays blocked). return ( <> - <button - type="button" - data-incomplete={isIncomplete} - data-streamdown="link" - data-resource-kind={kind ?? undefined} - title={isIncomplete ? undefined : href} - onClick={handleClick} - className={cn( - "wrap-anywhere appearance-none text-left font-medium text-primary underline", - className - )} - > - {Icon ? ( - <Icon - aria-hidden="true" - className="mr-0.5 inline size-[1em] align-[-0.15em] opacity-80" - /> - ) : null} - {children} - </button> + {kind === "web" ? ( + <ContextMenu> + <ContextMenuTrigger asChild>{button}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onSelect={() => + openUrlTarget(href, { + source: "transcript", + forceTarget: "builtin", + }) + } + > + {tLink("openBuiltin")} + </ContextMenuItem> + <ContextMenuItem + onSelect={() => + openUrlTarget(href, { + source: "transcript", + forceTarget: "system", + }) + } + > + {tLink("openSystem")} + </ContextMenuItem> + <ContextMenuItem onSelect={() => void copyTextToClipboard(href)}> + {tLink("copy")} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ) : ( + button + )} {linkSafety.renderModal ? linkSafety.renderModal(modalProps) : null} </> ) diff --git a/src/components/ai-elements/message-codeg-badge.test.tsx b/src/components/ai-elements/message-codeg-badge.test.tsx index 9d96d25c20..14dd59a0e7 100644 --- a/src/components/ai-elements/message-codeg-badge.test.tsx +++ b/src/components/ai-elements/message-codeg-badge.test.tsx @@ -10,6 +10,14 @@ import { describe, expect, it, vi } from "vitest" // These are ASSISTANT-path guards: `codeg://` reference links render as inline // badges via MarkdownLink + rehype-allow-codeg regardless of role. (User messages // no longer go through MessageResponse — see message/plain-text-with-badges.tsx.) +vi.mock("next-intl", () => { + const t = (key: string) => key + return { useTranslations: () => t } +}) +vi.mock("@/hooks/use-open-url-target", () => ({ + useOpenUrlTarget: () => () => ({ kind: "system", url: "" }), + isPrimaryModifier: () => false, +})) vi.mock("@/components/ai-elements/link-safety", () => ({ useStreamdownLinkSafety: () => ({ enabled: false }), })) diff --git a/src/components/ai-elements/message-windows-file-link.test.tsx b/src/components/ai-elements/message-windows-file-link.test.tsx index 9601744da7..9c78c90fc2 100644 --- a/src/components/ai-elements/message-windows-file-link.test.tsx +++ b/src/components/ai-elements/message-windows-file-link.test.tsx @@ -38,6 +38,7 @@ vi.mock("@/contexts/active-folder-context", () => ({ })) vi.mock("@/contexts/workspace-context", () => ({ + useOptionalWorkspaceActions: () => null, useWorkspaceActions: () => ({ openFilePreview: mocks.openFilePreview }), })) diff --git a/src/components/ai-elements/message-windows-path.test.tsx b/src/components/ai-elements/message-windows-path.test.tsx index 6032dd47f8..da8a5f430c 100644 --- a/src/components/ai-elements/message-windows-path.test.tsx +++ b/src/components/ai-elements/message-windows-path.test.tsx @@ -41,6 +41,7 @@ vi.mock("@/contexts/active-folder-context", () => ({ })) vi.mock("@/contexts/workspace-context", () => ({ + useOptionalWorkspaceActions: () => null, useWorkspaceActions: () => ({ openFilePreview: mocks.openFilePreview }), })) diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index ffe2fa67e6..01f66d8cd3 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -23,6 +23,10 @@ import { } from "@/lib/terminal/keybar" import { TermKeybar } from "@/components/terminal/term-keybar" import { useZoomLevel, useTerminalFont } from "@/hooks/use-appearance" +import { + isPrimaryModifier, + useOpenUrlTarget, +} from "@/hooks/use-open-url-target" import { detectPlatform } from "@/hooks/use-platform" import type { TerminalEvent } from "@/lib/types" import type { ITerminalAddon, Terminal as XTermTerminal } from "@xterm/xterm" @@ -97,6 +101,11 @@ export function TerminalView({ const isActiveRef = useRef(isActive) const isVisibleRef = useRef(isVisible) const onProcessExitedRef = useRef(onProcessExited) + // Link clicks route through the app's link decision (built-in browser vs + // system browser, ⌘/Ctrl inverts). xterm's default handler is a bare + // `window.open`, which the desktop webview turns into a dead click. + const openUrlTarget = useOpenUrlTarget() + const openUrlTargetRef = useRef(openUrlTarget) const { zoomLevel } = useZoomLevel() const { terminalFontStack, terminalFontSize, terminalLigatures } = useTerminalFont() @@ -200,6 +209,10 @@ export function TerminalView({ onProcessExitedRef.current = onProcessExited }, [onProcessExited]) + useEffect(() => { + openUrlTargetRef.current = openUrlTarget + }, [openUrlTarget]) + useEffect(() => { let cancelled = false let cleanup: (() => void) | undefined @@ -212,7 +225,12 @@ export function TerminalView({ if (cancelled || !containerRef.current) return const fitAddon = new FitAddon() - const webLinksAddon = new WebLinksAddon() + const webLinksAddon = new WebLinksAddon((event, uri) => { + openUrlTargetRef.current(uri, { + source: "terminal", + modifier: isPrimaryModifier(event), + }) + }) const term = new Terminal({ cursorBlink: true, diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 608b6ff144..46556a4c66 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -2780,6 +2780,12 @@ export function useWorkspaceActions(): WorkspaceActionsValue { return ctx } +/** The actions, or `null` outside a `WorkspaceProvider` — for hooks that can + * degrade (the link opener falls back to the system browser). */ +export function useOptionalWorkspaceActions(): WorkspaceActionsValue | null { + return useContext(WorkspaceActionsContext) +} + // Low-frequency layout state (mode / activePane / filesMaximized). Changes // only on fusion transitions, pane switches, and maximize toggles. export function useWorkspaceView(): WorkspaceViewValue { diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx new file mode 100644 index 0000000000..6f07b2bc56 --- /dev/null +++ b/src/hooks/use-open-url-target.test.tsx @@ -0,0 +1,179 @@ +import { renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + openBrowserTab: vi.fn(() => "browser:new"), + viewerOpen: vi.fn(), + openInSystemBrowser: vi.fn(() => Promise.resolve()), + openWithOsHandler: vi.fn(() => Promise.resolve()), + toast: vi.fn(), + remote: false, + route: { isConversations: true } as { isConversations: boolean } | null, + viewerHost: null as { open: (r: unknown) => void } | null, + actions: null as { openBrowserTab: (url: string) => string | null } | null, +})) + +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })) +vi.mock("sonner", () => ({ toast: mocks.toast })) +vi.mock("@/contexts/workspace-context", () => ({ + useOptionalWorkspaceActions: () => mocks.actions, +})) +vi.mock("@/contexts/workbench-route-context", () => ({ + useOptionalWorkbenchRoute: () => mocks.route, +})) +vi.mock("@/components/message/session-viewer-host-context", () => ({ + useSessionViewerHost: () => mocks.viewerHost, +})) +vi.mock("@/lib/link-open", () => ({ + openInSystemBrowser: mocks.openInSystemBrowser, + openWithOsHandler: mocks.openWithOsHandler, +})) +vi.mock("@/lib/transport", () => ({ + isRemoteDesktopMode: () => mocks.remote, + isDesktop: () => true, + getTransport: () => ({ call: vi.fn() }), +})) + +import { setBrowserCapabilitiesForTests } from "@/lib/browser/browser-api" +import { resetBrowserPrefsForTests } from "@/lib/browser/browser-prefs" +import { isPrimaryModifier, useOpenUrlTarget } from "./use-open-url-target" + +const AVAILABLE = { + available: true, + surface: "child" as const, + platform: "macos", + channel: "native" as const, + reasons: [], +} + +describe("useOpenUrlTarget", () => { + beforeEach(() => { + resetBrowserPrefsForTests() + setBrowserCapabilitiesForTests(AVAILABLE) + mocks.openBrowserTab.mockClear() + mocks.viewerOpen.mockClear() + mocks.openInSystemBrowser.mockClear() + mocks.openWithOsHandler.mockClear() + mocks.toast.mockClear() + mocks.remote = false + mocks.route = { isConversations: true } + mocks.viewerHost = null + mocks.actions = { openBrowserTab: mocks.openBrowserTab } + }) + afterEach(() => { + resetBrowserPrefsForTests() + setBrowserCapabilitiesForTests(null) + }) + + it("opens a web link in a built-in tab by default, synchronously, and toasts once", () => { + const { result } = renderHook(() => useOpenUrlTarget()) + const action = result.current("https://example.com/docs", { + source: "transcript", + }) + expect(action).toMatchObject({ kind: "builtin", placement: "tab" }) + // Called inside the same call stack — no await in between. + expect(mocks.openBrowserTab).toHaveBeenCalledWith( + "https://example.com/docs" + ) + expect(mocks.toast).toHaveBeenCalledTimes(1) + result.current("https://example.com/2", { source: "transcript" }) + expect(mocks.toast).toHaveBeenCalledTimes(1) + }) + + it("⌘/Ctrl inverts to the system browser, still synchronously", () => { + const { result } = renderHook(() => useOpenUrlTarget()) + const action = result.current("https://example.com/", { + source: "terminal", + modifier: true, + }) + expect(action.kind).toBe("system") + expect(mocks.openInSystemBrowser).toHaveBeenCalledWith( + "https://example.com/" + ) + expect(mocks.openBrowserTab).not.toHaveBeenCalled() + }) + + it("falls back to the system browser when no built-in browser exists", () => { + setBrowserCapabilitiesForTests(null) // not resolved yet + const { result } = renderHook(() => useOpenUrlTarget()) + expect( + result.current("https://example.com/", { source: "transcript" }).kind + ).toBe("system") + setBrowserCapabilitiesForTests({ ...AVAILABLE, available: false }) + expect( + result.current("https://example.com/", { source: "toolCard" }).kind + ).toBe("system") + expect(mocks.openInSystemBrowser).toHaveBeenCalledTimes(2) + }) + + it("uses the viewer drawer under a full-page route", () => { + mocks.route = { isConversations: false } + mocks.viewerHost = { open: mocks.viewerOpen } + const { result } = renderHook(() => useOpenUrlTarget()) + const action = result.current("https://example.com/", { + source: "transcript", + }) + expect(action).toMatchObject({ kind: "builtin", placement: "drawer" }) + expect(mocks.viewerOpen).toHaveBeenCalledWith({ + kind: "browser", + url: "https://example.com/", + }) + expect(mocks.openBrowserTab).not.toHaveBeenCalled() + }) + + it("without a workspace provider the built-in target is unavailable", () => { + mocks.actions = null + const { result } = renderHook(() => useOpenUrlTarget()) + expect( + result.current("https://example.com/", { source: "editor" }).kind + ).toBe("system") + }) + + it("keeps a remote-workspace loopback address built-in even with the modifier", () => { + mocks.remote = true + const { result } = renderHook(() => useOpenUrlTarget()) + const action = result.current("http://localhost:3000/", { + source: "transcript", + modifier: true, + }) + expect(action).toMatchObject({ kind: "builtin", remoteOverride: true }) + expect(mocks.openBrowserTab).toHaveBeenCalledWith("http://localhost:3000/") + }) + + it("routes mailto/tel to the OS handler and reports unsupported schemes", () => { + const { result } = renderHook(() => useOpenUrlTarget()) + expect(result.current("mailto:a@b.c", { source: "transcript" }).kind).toBe( + "os-handler" + ) + expect(mocks.openWithOsHandler).toHaveBeenCalledWith("mailto:a@b.c") + expect( + result.current("vscode://x", { source: "transcript" }) + ).toMatchObject({ + kind: "reject", + reason: "unsupported-scheme", + }) + }) + + it("explicit menu choices override the preference", () => { + const { result } = renderHook(() => useOpenUrlTarget()) + expect( + result.current("https://example.com/", { + source: "transcript", + forceTarget: "system", + }).kind + ).toBe("system") + }) +}) + +describe("isPrimaryModifier", () => { + it("reads ⌘ on macOS and Ctrl elsewhere", () => { + const platform = vi.spyOn(navigator, "platform", "get") + platform.mockReturnValue("MacIntel") + expect(isPrimaryModifier({ metaKey: true, ctrlKey: false })).toBe(true) + expect(isPrimaryModifier({ metaKey: false, ctrlKey: true })).toBe(false) + platform.mockReturnValue("Win32") + expect(isPrimaryModifier({ metaKey: true, ctrlKey: false })).toBe(false) + expect(isPrimaryModifier({ metaKey: false, ctrlKey: true })).toBe(true) + platform.mockRestore() + }) +}) diff --git a/src/hooks/use-open-url-target.ts b/src/hooks/use-open-url-target.ts new file mode 100644 index 0000000000..39d5223c57 --- /dev/null +++ b/src/hooks/use-open-url-target.ts @@ -0,0 +1,119 @@ +"use client" + +import { useCallback } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { useSessionViewerHost } from "@/components/message/session-viewer-host-context" +import { useOptionalWorkbenchRoute } from "@/contexts/workbench-route-context" +import { useOptionalWorkspaceActions } from "@/contexts/workspace-context" +import { browserCapabilitiesSnapshot } from "@/lib/browser/browser-api" +import { + getBrowserPrefs, + markBrowserFirstOpenSeen, + setAllDefaultLinkTargets, + type LinkSource, + type LinkTarget, +} from "@/lib/browser/browser-prefs" +import { openInSystemBrowser, openWithOsHandler } from "@/lib/link-open" +import { + resolveLinkAction, + type LinkAction, + type LinkSurface, +} from "@/lib/resolve-link-action" +import { isRemoteDesktopMode } from "@/lib/transport" + +export interface OpenUrlOptions { + source: LinkSource + /** Primary modifier (⌘ on macOS, Ctrl elsewhere) held during the gesture. */ + modifier?: boolean + /** Explicit target chosen from a menu; ignores the modifier and preference. */ + forceTarget?: LinkTarget +} + +/** ⌘ on macOS, Ctrl elsewhere — the "open the other way" modifier. */ +export function isPrimaryModifier(event: { + metaKey?: boolean + ctrlKey?: boolean +}): boolean { + const mac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform) + return mac ? Boolean(event.metaKey) : Boolean(event.ctrlKey) +} + +/** + * Decide and execute where an http(s) (or mailto/tel) address goes, INSIDE the + * caller's call stack. The decision itself (`resolveLinkAction`) is a pure + * function of a preferences snapshot and this surface; only the execution + * touches the app: the built-in browser tab (or the transcript's side panel + * under a full-page route), the system browser, or the OS handler. + * + * Returns the action taken so callers can react (a `reject` shows nothing + * here — the caller owns the error toast wording). Local file paths are not + * handled: they are `useOpenFileTarget`'s job and never reach this hook. + */ +export function useOpenUrlTarget() { + const t = useTranslations("Browser.toast") + // Null outside the workspace (no tab strip to open into): the built-in + // target is then simply unavailable and links go to the system browser. + const openBrowserTab = useOptionalWorkspaceActions()?.openBrowserTab ?? null + const route = useOptionalWorkbenchRoute() + const viewerHost = useSessionViewerHost() + const fileColumnVisible = route ? route.isConversations : true + + return useCallback( + (url: string, options: OpenUrlOptions): LinkAction => { + const capabilities = browserCapabilitiesSnapshot() + const surface: LinkSurface = { + builtinAvailable: + (capabilities?.available ?? false) && + (openBrowserTab !== null || viewerHost !== null), + fileColumnVisible, + viewerHostAvailable: viewerHost !== null, + remoteDesktop: isRemoteDesktopMode(), + } + const prefs = getBrowserPrefs() + const action = resolveLinkAction(url, { + source: options.source, + modifier: options.modifier ?? false, + forceTarget: options.forceTarget, + surface, + prefs, + }) + switch (action.kind) { + case "system": + void openInSystemBrowser(action.url) + break + case "os-handler": + void openWithOsHandler(action.url) + break + case "builtin": { + if (action.placement === "drawer" && viewerHost) { + viewerHost.open({ kind: "browser", url: action.url }) + } else if (openBrowserTab) { + openBrowserTab(action.url) + } else if (viewerHost) { + viewerHost.open({ kind: "browser", url: action.url }) + } + if (!prefs.firstOpenSeen) { + markBrowserFirstOpenSeen() + toast(t("firstOpen"), { + description: t("firstOpenHint"), + action: { + label: t("useSystemAlways"), + onClick: () => setAllDefaultLinkTargets("system"), + }, + }) + } + break + } + case "file": + case "reject": + break + } + return action + }, + [fileColumnVisible, openBrowserTab, t, viewerHost] + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b4ef01d006..b740e9d404 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5796,6 +5796,16 @@ "description": "صفحة ويب فُتحت من المحادثة", "openInWorkspace": "فتح في مساحة العمل", "cannotOpen": "لا يمكن فتح هذا العنوان هنا." + }, + "toast": { + "firstOpen": "تم الفتح في المتصفح المدمج", + "firstOpenHint": "اضغط ⌘/Ctrl أثناء النقر على رابط لفتحه في متصفح النظام بدلًا من ذلك. يمكن تغيير الافتراضي من الإعدادات.", + "useSystemAlways": "استخدام متصفح النظام دائمًا" + }, + "link": { + "openBuiltin": "فتح في المتصفح المدمج", + "openSystem": "فتح في متصفح النظام", + "copy": "نسخ الرابط" } } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9ccc937cbc..94b7f3ced2 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5796,6 +5796,16 @@ "description": "Eine aus der Unterhaltung geöffnete Webseite", "openInWorkspace": "Im Arbeitsbereich öffnen", "cannotOpen": "Diese Adresse kann hier nicht geöffnet werden." + }, + "toast": { + "firstOpen": "Im integrierten Browser geöffnet", + "firstOpenHint": "⌘/Strg+Klick auf einen Link öffnet ihn stattdessen im Systembrowser. Die Voreinstellung lässt sich in den Einstellungen ändern.", + "useSystemAlways": "Immer den Systembrowser verwenden" + }, + "link": { + "openBuiltin": "Im integrierten Browser öffnen", + "openSystem": "Im Systembrowser öffnen", + "copy": "Link kopieren" } } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 4038688cc1..6fcde524b4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5796,6 +5796,16 @@ "description": "A web page opened from the conversation", "openInWorkspace": "Open in workspace", "cannotOpen": "This address can't be opened here." + }, + "toast": { + "firstOpen": "Opened in the built-in browser", + "firstOpenHint": "⌘/Ctrl-click a link to open it in your system browser instead. Change the default in Settings.", + "useSystemAlways": "Always use system browser" + }, + "link": { + "openBuiltin": "Open in built-in browser", + "openSystem": "Open in system browser", + "copy": "Copy link" } } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7764a2417b..5813af0f9d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5796,6 +5796,16 @@ "description": "Una página web abierta desde la conversación", "openInWorkspace": "Abrir en el espacio de trabajo", "cannotOpen": "Esta dirección no se puede abrir aquí." + }, + "toast": { + "firstOpen": "Abierto en el navegador integrado", + "firstOpenHint": "Haz ⌘/Ctrl+clic en un enlace para abrirlo en el navegador del sistema. Cambia el valor predeterminado en Ajustes.", + "useSystemAlways": "Usar siempre el navegador del sistema" + }, + "link": { + "openBuiltin": "Abrir en el navegador integrado", + "openSystem": "Abrir en el navegador del sistema", + "copy": "Copiar enlace" } } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index baf2954971..7f5f975c8d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5796,6 +5796,16 @@ "description": "Une page web ouverte depuis la conversation", "openInWorkspace": "Ouvrir dans l'espace de travail", "cannotOpen": "Cette adresse ne peut pas être ouverte ici." + }, + "toast": { + "firstOpen": "Ouvert dans le navigateur intégré", + "firstOpenHint": "⌘/Ctrl+clic sur un lien pour l'ouvrir dans le navigateur système. Le choix par défaut se modifie dans les Réglages.", + "useSystemAlways": "Toujours utiliser le navigateur système" + }, + "link": { + "openBuiltin": "Ouvrir dans le navigateur intégré", + "openSystem": "Ouvrir dans le navigateur système", + "copy": "Copier le lien" } } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c3836fe095..3d6ef0575a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5796,6 +5796,16 @@ "description": "会話から開いたウェブページ", "openInWorkspace": "ワークスペースで開く", "cannotOpen": "このアドレスはここでは開けません。" + }, + "toast": { + "firstOpen": "内蔵ブラウザで開きました", + "firstOpenHint": "⌘/Ctrl を押しながらリンクをクリックするとシステムのブラウザで開きます。既定は設定で変更できます。", + "useSystemAlways": "常にシステムのブラウザを使う" + }, + "link": { + "openBuiltin": "内蔵ブラウザで開く", + "openSystem": "システムのブラウザで開く", + "copy": "リンクをコピー" } } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 50c00f4342..0ed5ac3864 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5796,6 +5796,16 @@ "description": "대화에서 연 웹 페이지", "openInWorkspace": "작업 공간에서 열기", "cannotOpen": "이 주소는 여기에서 열 수 없습니다." + }, + "toast": { + "firstOpen": "내장 브라우저에서 열었습니다", + "firstOpenHint": "⌘/Ctrl을 누른 채 링크를 클릭하면 시스템 브라우저에서 열립니다. 기본값은 설정에서 바꿀 수 있습니다.", + "useSystemAlways": "항상 시스템 브라우저 사용" + }, + "link": { + "openBuiltin": "내장 브라우저에서 열기", + "openSystem": "시스템 브라우저에서 열기", + "copy": "링크 복사" } } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f5135960c7..13726a91a5 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5796,6 +5796,16 @@ "description": "Uma página da web aberta a partir da conversa", "openInWorkspace": "Abrir no espaço de trabalho", "cannotOpen": "Este endereço não pode ser aberto aqui." + }, + "toast": { + "firstOpen": "Aberto no navegador integrado", + "firstOpenHint": "⌘/Ctrl+clique em um link para abri-lo no navegador do sistema. Altere o padrão em Configurações.", + "useSystemAlways": "Sempre usar o navegador do sistema" + }, + "link": { + "openBuiltin": "Abrir no navegador integrado", + "openSystem": "Abrir no navegador do sistema", + "copy": "Copiar link" } } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b764539ae2..1b2e7ee5c0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5796,6 +5796,16 @@ "description": "从会话中打开的网页", "openInWorkspace": "在工作区中打开", "cannotOpen": "无法在此打开该地址。" + }, + "toast": { + "firstOpen": "已在内置浏览器中打开", + "firstOpenHint": "按住 ⌘/Ctrl 点击链接可改用系统浏览器。默认方式可在设置中修改。", + "useSystemAlways": "始终使用系统浏览器" + }, + "link": { + "openBuiltin": "在内置浏览器中打开", + "openSystem": "在系统浏览器中打开", + "copy": "复制链接" } } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 23cfe1523a..d367150aa0 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5796,6 +5796,16 @@ "description": "從對話中開啟的網頁", "openInWorkspace": "在工作區中開啟", "cannotOpen": "無法在此開啟此網址。" + }, + "toast": { + "firstOpen": "已在內建瀏覽器中開啟", + "firstOpenHint": "按住 ⌘/Ctrl 點擊連結可改用系統瀏覽器。預設方式可在設定中修改。", + "useSystemAlways": "一律使用系統瀏覽器" + }, + "link": { + "openBuiltin": "在內建瀏覽器中開啟", + "openSystem": "在系統瀏覽器中開啟", + "copy": "複製連結" } } } diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index daca06d857..257fe53e2f 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -21,13 +21,21 @@ const UNAVAILABLE: BrowserCapabilities = { } let capabilitiesPromise: Promise<BrowserCapabilities> | null = null +let resolvedCapabilities: BrowserCapabilities | null = null /** Cached for the session: the answer cannot change while the app runs. */ export function browserCapabilities(): Promise<BrowserCapabilities> { - if (!isDesktop()) return Promise.resolve(UNAVAILABLE) + if (!isDesktop()) { + resolvedCapabilities = UNAVAILABLE + return Promise.resolve(UNAVAILABLE) + } if (!capabilitiesPromise) { capabilitiesPromise = getTransport() .call<BrowserCapabilities>("browser_capabilities", {}) + .then((caps) => { + resolvedCapabilities = caps + return caps + }) .catch((error: unknown) => { capabilitiesPromise = null return { @@ -39,9 +47,27 @@ export function browserCapabilities(): Promise<BrowserCapabilities> { return capabilitiesPromise } +/** + * Synchronous view of the answer, for decisions that must happen inside a + * click's call stack. `null` until `browserCapabilities()` has resolved once + * (the events bridge asks at startup); callers treat null as "not available" + * and fall back to the system browser, which is always a safe answer. + */ +export function browserCapabilitiesSnapshot(): BrowserCapabilities | null { + return resolvedCapabilities +} + /** Tests only. */ export function resetBrowserCapabilitiesCacheForTests(): void { capabilitiesPromise = null + resolvedCapabilities = null +} + +/** Tests only: pretend the capabilities already resolved. */ +export function setBrowserCapabilitiesForTests( + caps: BrowserCapabilities | null +): void { + resolvedCapabilities = caps } export interface OpenBrowserTabParams { diff --git a/src/lib/link-open.test.ts b/src/lib/link-open.test.ts new file mode 100644 index 0000000000..ad0a1b1d13 --- /dev/null +++ b/src/lib/link-open.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + desktop: false, + remote: null as number | null, + openUrl: vi.fn(() => Promise.resolve()), +})) +vi.mock("@/lib/transport", () => ({ + isDesktop: () => mocks.desktop, + getActiveRemoteConnectionId: () => mocks.remote, +})) +vi.mock("@/lib/platform", () => ({ openUrl: mocks.openUrl })) + +import { openInSystemBrowser, openWithOsHandler } from "./link-open" + +describe("link-open", () => { + beforeEach(() => { + mocks.desktop = false + mocks.remote = null + mocks.openUrl.mockClear() + vi.spyOn(window, "open").mockReturnValue(null) + }) + afterEach(() => vi.restoreAllMocks()) + + it("web mode: system browser = synchronous window.open with noreferrer", async () => { + await openInSystemBrowser("https://example.com/") + expect(window.open).toHaveBeenCalledWith( + "https://example.com/", + "_blank", + "noreferrer" + ) + expect(mocks.openUrl).not.toHaveBeenCalled() + }) + + it("desktop (local and remote): system browser = the opener plugin", async () => { + mocks.desktop = true + await openInSystemBrowser("https://example.com/") + mocks.remote = 3 + await openInSystemBrowser("https://example.com/2") + expect(mocks.openUrl).toHaveBeenCalledTimes(2) + expect(window.open).not.toHaveBeenCalled() + }) + + it("mailto/tel: synthetic anchor on web and remote windows, opener plugin locally", async () => { + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}) + await openWithOsHandler("mailto:a@b.c") + expect(click).toHaveBeenCalledTimes(1) + mocks.desktop = true + mocks.remote = 7 + await openWithOsHandler("tel:+1") + expect(click).toHaveBeenCalledTimes(2) + mocks.remote = null + await openWithOsHandler("tel:+2") + expect(mocks.openUrl).toHaveBeenCalledWith("tel:+2") + }) +}) diff --git a/src/lib/link-open.ts b/src/lib/link-open.ts new file mode 100644 index 0000000000..8f59deeed6 --- /dev/null +++ b/src/lib/link-open.ts @@ -0,0 +1,86 @@ +// The primitive ways of opening an external address from the app, shared by +// the transcript link flow and the built-in browser's link decision. No React. + +import { getActiveRemoteConnectionId, isDesktop } from "@/lib/transport" +import { openUrl } from "@/lib/platform" + +/** + * True when `window.open` actually opens something — i.e. a real browser. + * + * NOT the same question as `isWebOpenerEnvironment` below. A Tauri window bound + * to a remote codeg-server is still a TAURI WEBVIEW, and a webview that + * registers no new-window handler opens nothing at all for `window.open` (wry + * answers with nil on macOS, `SetHandled(true)` on Windows). Lumping remote + * windows in with web mode here left every http(s) link in a remote workspace + * silently dead; they must take the opener-plugin path instead, which + * `capabilities/default.json` grants to the `remote-*` windows. + */ +export function windowOpenReachesABrowser(): boolean { + return !isDesktop() +} + +/** + * True when a `mailto:`/`tel:` URL should be handed to the OS through a + * synthetic anchor rather than the Tauri opener plugin — pure web, or a Tauri + * window bound to a remote codeg-server. + * + * The remote arm stays deliberately: unlike `window.open`, a synthetic anchor + * DOES reach the OS handler from inside a webview, and it sidesteps the + * question of whether the opener capability covers non-http(s) schemes. + */ +export function isWebOpenerEnvironment(): boolean { + return !isDesktop() || getActiveRemoteConnectionId() !== null +} + +/** + * Trigger an OS-registered protocol handler (mail client, dialer) from a + * browser without leaving an empty tab. The synthetic anchor has no + * `target`, so the browser hands the URL to the OS handler and stays on + * the current page. + */ +export function dispatchOsHandlerUrl(url: string): void { + const anchor = document.createElement("a") + anchor.href = url + anchor.rel = "noreferrer noopener" + document.body.appendChild(anchor) + try { + anchor.click() + } finally { + anchor.remove() + } +} + +/** + * Open an external URL in a new tab. Callers MUST invoke this inside the + * click's own call stack — see `openLinkWithSafety` in link-safety.tsx. + */ +export function openExternalTab(url: string): void { + // `noreferrer` (which implies `noopener`) matters for AI-authored links: the + // opened page gets no `window.opener` handle back into the app and no + // Referer. It also makes `window.open` return null even on success (HTML + // window open steps 12 and 17), so the return value carries no signal — + // don't test it for a "popup blocked" check, it would fire on every success. + window.open(url, "_blank", "noreferrer") +} + +/** + * Open an http(s) URL in the SYSTEM browser: `window.open` where that reaches + * a browser (web mode — synchronous, inside the gesture), the Tauri opener + * plugin otherwise (desktop, including remote windows; no gesture needed). + */ +export function openInSystemBrowser(url: string): Promise<void> { + if (windowOpenReachesABrowser()) { + openExternalTab(url) + return Promise.resolve() + } + return openUrl(url) +} + +/** `mailto:` / `tel:` — the OS handler route. */ +export function openWithOsHandler(url: string): Promise<void> { + if (isWebOpenerEnvironment()) { + dispatchOsHandlerUrl(url) + return Promise.resolve() + } + return openUrl(url) +} From 5bb204429d2c676b493b23b878470f2dbf820bc7 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 20:36:07 +0800 Subject: [PATCH 09/79] feat(browser): turn modifier-clicks into background tabs and guard capability labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ⌘/Ctrl-click on a plain anchor lets the engine navigate the tab in place; the navigation handler now consumes the matching gesture from the tab's ring (click, button 0, primary modifier, no target/download, same href without fragment) and asks the frontend for a background tab instead, cancelling the in-place load. Also adds a test that no capability window pattern ever covers browser-*/browser-popup-*/codeg-doc-* labels or uses a bare wildcard. --- src-tauri/src/browser/mod.rs | 41 +++++++++ src-tauri/src/browser/registry.rs | 117 ++++++++++++++++++++++++- src-tauri/src/browser/surface_child.rs | 28 +++++- 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index 6cfeeaccfb..7ddbd1c6d2 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -51,3 +51,44 @@ pub const TAB_LABEL_PREFIX: &str = "browser-"; pub fn tab_label(tab_id: &str) -> String { format!("{TAB_LABEL_PREFIX}{tab_id}") } + +#[cfg(test)] +mod tests { + use super::TAB_LABEL_PREFIX; + + /// A browser tab must have NO Tauri IPC: its label (and the popup and + /// document-guest prefixes) may never appear in a capability's window + /// list, and no capability may use a bare wildcard that would cover it. + #[test] + fn browser_labels_are_absent_from_every_capability() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("capabilities"); + let mut checked = 0; + for entry in std::fs::read_dir(&dir).expect("capabilities dir") { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let raw = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let windows = json["windows"].as_array().cloned().unwrap_or_default(); + for pattern in windows.iter().filter_map(|w| w.as_str()) { + assert_ne!(pattern, "*", "{}: a bare wildcard covers browser tabs", path.display()); + assert_ne!(pattern, "**", "{}: a bare wildcard covers browser tabs", path.display()); + for forbidden in [TAB_LABEL_PREFIX, "browser-popup-", "codeg-doc-"] { + assert!( + !pattern.starts_with(forbidden), + "{}: capability window pattern {pattern:?} grants IPC to browser surfaces", + path.display() + ); + } + } + checked += 1; + } + assert!(checked >= 1, "no capability files found"); + } + + #[test] + fn tab_labels_carry_the_prefix() { + assert_eq!(super::tab_label("abc"), "browser-abc"); + } +} diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 057f5cdd5c..cb7833541e 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Mutex, MutexGuard}; -use std::time::Instant; +use std::time::{Duration, Instant}; use serde_json::Value; @@ -150,6 +150,30 @@ impl BrowserRegistry { }); } + /// Consume a recent (`within`) modifier-click on a plain anchor whose + /// resolved href is `url`: the page let the engine navigate in place, and + /// the host turns that into a background tab instead. Only a `click` + /// with button 0, the platform's primary modifier (⌘ on macOS, Ctrl + /// elsewhere), no `target`, and no `download` qualifies; the record is + /// removed so a single gesture cannot spawn two tabs. + pub fn take_modifier_click(&self, tab_id: &str, url: &str, within: Duration) -> bool { + let wanted = normalize_for_match(url); + let mut tabs = self.lock(); + let Some(tab) = tabs.get_mut(tab_id) else { + return false; + }; + let idx = tab.gestures.iter().rposition(|g| { + g.received.elapsed() <= within && gesture_is_modifier_click(&g.payload, &wanted) + }); + match idx { + Some(i) => { + tab.gestures.remove(i); + true + } + None => false, + } + } + /// Newest first. pub fn recent_gestures(&self, tab_id: &str) -> Vec<GestureRecord> { self.lock() @@ -166,3 +190,94 @@ impl BrowserRegistry { self.lock().is_empty() } } + +fn normalize_for_match(url: &str) -> String { + // Compare without the fragment: an anchor's `href` and the navigation it + // produces agree up to the fragment, which the engine may drop. + match tauri::Url::parse(url) { + Ok(mut u) => { + u.set_fragment(None); + u.to_string() + } + Err(_) => url.to_string(), + } +} + +fn gesture_is_modifier_click(payload: &Value, wanted: &str) -> bool { + if payload.get("type").and_then(Value::as_str) != Some("click") { + return false; + } + if payload.get("button").and_then(Value::as_i64) != Some(0) { + return false; + } + let modifiers = payload.get("modifiers"); + let flag = |k: &str| { + modifiers + .and_then(|m| m.get(k)) + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let primary = if cfg!(target_os = "macos") { flag("meta") } else { flag("ctrl") }; + if !primary { + return false; + } + let Some(anchor) = payload.get("anchor").filter(|a| !a.is_null()) else { + return false; + }; + if anchor.get("download").and_then(Value::as_bool).unwrap_or(false) { + return false; + } + let target = anchor.get("target").and_then(Value::as_str).unwrap_or(""); + if !(target.is_empty() || target.eq_ignore_ascii_case("_self")) { + return false; + } + anchor + .get("href") + .and_then(Value::as_str) + .map(|h| normalize_for_match(h) == wanted) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn click(href: &str, meta: bool, ctrl: bool, extra: Value) -> Value { + let mut v = json!({ + "type": "click", "button": 0, + "modifiers": { "meta": meta, "ctrl": ctrl, "shift": false, "alt": false }, + "anchor": { "href": href, "target": "", "download": false } + }); + if let (Some(obj), Some(more)) = (v.as_object_mut(), extra.as_object()) { + for (k, val) in more { + if k == "anchor" { + if let Some(a) = obj.get_mut("anchor").and_then(Value::as_object_mut) { + for (ak, av) in val.as_object().unwrap() { + a.insert(ak.clone(), av.clone()); + } + } + } else { + obj.insert(k.clone(), val.clone()); + } + } + } + v + } + + #[test] + fn modifier_click_matching_rules() { + let primary = cfg!(target_os = "macos"); + let (meta, ctrl) = (primary, !primary); + let wanted = normalize_for_match("https://example.com/a?b=1#frag"); + assert!(gesture_is_modifier_click(&click("https://example.com/a?b=1", meta, ctrl, json!({})), &wanted)); + // Wrong modifier, plain click, middle click, _blank, download, other href: no match. + assert!(!gesture_is_modifier_click(&click("https://example.com/a?b=1", !meta, !ctrl, json!({})), &wanted)); + assert!(!gesture_is_modifier_click(&click("https://example.com/a?b=1", false, false, json!({})), &wanted)); + assert!(!gesture_is_modifier_click(&click("https://example.com/a?b=1", meta, ctrl, json!({"button": 1})), &wanted)); + assert!(!gesture_is_modifier_click(&click("https://example.com/a?b=1", meta, ctrl, json!({"anchor": {"target": "_blank"}})), &wanted)); + assert!(!gesture_is_modifier_click(&click("https://example.com/a?b=1", meta, ctrl, json!({"anchor": {"download": true}})), &wanted)); + assert!(!gesture_is_modifier_click(&click("https://example.com/other", meta, ctrl, json!({})), &wanted)); + assert!(!gesture_is_modifier_click(&json!({"type": "keydown"}), &wanted)); + } +} diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 68c4a7a0af..a9d8535cac 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -38,7 +38,8 @@ use super::policy; use super::registry::{BrowserRegistry, BrowserTab}; use super::surface::BrowserSurface; use super::types::{ - Bounds, BrowserPopupPayload, BrowserTabState, ChannelKind, PopupPresentation, SurfaceKind, + Bounds, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, ChannelKind, + PopupPresentation, SurfaceKind, }; #[cfg(target_os = "macos")] use super::shim::macos as shim; @@ -377,6 +378,8 @@ fn build_child( configuration: Option<OpenerConfiguration>, ) -> Result<wry::WebView, String> { let nav_id = tab_id.to_string(); + let nav_app = app.clone(); + let nav_owner = owner.label().to_string(); #[allow(unused_mut)] let mut builder = WebViewBuilder::new() .with_id(label) @@ -391,8 +394,29 @@ fn build_child( .unwrap_or(false); if !allowed { tracing::info!("[browser] tab {nav_id} blocked navigation to {url}"); + return false; } - allowed + // ⌘/Ctrl-click on a plain anchor: the page did not prevent the + // default, so the engine is about to navigate this tab. Browsers + // open a background tab instead; so do we — the host cancels the + // in-place navigation and asks the frontend for a new tab. (No + // JS involved: a page can always add a later listener, so only + // the navigation itself is a reliable signal.) + if let Some(registry) = nav_app.try_state::<BrowserRegistry>() { + if registry.take_modifier_click(&nav_id, &url, POPUP_GESTURE_WINDOW) { + events::emit_open_request( + &nav_app, + &BrowserOpenRequestPayload { + url: url.clone(), + source: "modifier-click".to_string(), + activate: false, + owner_window: Some(nav_owner.clone()), + }, + ); + return false; + } + } + true }) .with_on_page_load_handler({ let app = app.clone(); From 3e7dc926add9b15e2215f2de4a6a845f1afd7537 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 20:41:08 +0800 Subject: [PATCH 10/79] feat(browser): place modifier-click tabs right after their opener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open request now carries the originating tab; the workspace inserts the new record next to it (and inherits its folder) instead of appending at the end, matching how browsers place ⌘/Ctrl-clicked tabs. --- src-tauri/src/browser/smoke.rs | 1 + src-tauri/src/browser/surface_child.rs | 1 + src-tauri/src/browser/types.rs | 3 ++ .../browser/browser-events-bridge.test.tsx | 14 +++++++ .../browser/browser-events-bridge.tsx | 8 +++- src/contexts/workspace-context.test.tsx | 32 ++++++++++++++++ src/contexts/workspace-context.tsx | 37 +++++++++++++++---- src/lib/browser/types.ts | 2 + 8 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 1d7ea2a3d7..66748fe9e3 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -371,6 +371,7 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { source: "smoke".to_string(), activate: cmd.get("activate").and_then(Value::as_bool).unwrap_or(true), owner_window: cmd.get("owner").and_then(Value::as_str).map(str::to_string), + opener_tab_id: cmd.get("opener").and_then(Value::as_str).map(str::to_string), }, ); Ok(Value::Null) diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index a9d8535cac..d2294f2bd5 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -411,6 +411,7 @@ fn build_child( source: "modifier-click".to_string(), activate: false, owner_window: Some(nav_owner.clone()), + opener_tab_id: Some(nav_id.clone()), }, ); return false; diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index a2a0280911..084bdeb51c 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -127,6 +127,9 @@ pub struct BrowserOpenRequestPayload { pub activate: bool, /// Window whose workspace should open it (`main` when absent). pub owner_window: Option<String>, + /// Tab the request originated in (a modifier-click inside it). The + /// frontend inserts the new tab right after it, like a browser does. + pub opener_tab_id: Option<String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 134186075d..e0375e9505 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -112,6 +112,20 @@ describe("BrowserEventsBridge", () => { ownerWindow: "remote-workspace-3", }) expect(mocks.openBrowserTab).toHaveBeenCalledTimes(1) + // A modifier-click names its opener; the workspace id is derived here so + // the context can place the new tab right after it. + mocks.handlers.get("browser://open-request")!({ + url: "https://example.com/next-to-opener", + source: "modifier-click", + activate: false, + ownerWindow: "main", + openerTabId: "abc", + }) + expect(mocks.openBrowserTab).toHaveBeenCalledTimes(2) + expect(mocks.openBrowserTab).toHaveBeenLastCalledWith( + "https://example.com/next-to-opener", + { activate: false, openerTabId: "browser:abc" } + ) mocks.handlers.get("browser://state")!({ tabId: "abc", diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 24dfa4fb8c..2e86b21870 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -102,7 +102,13 @@ export function BrowserEventsBridge() { // Every window hears every event; only the addressed one acts. const target = request.ownerWindow ?? "main" if (target !== getCurrentWindowLabel()) return - openBrowserTab(request.url, { activate: request.activate }) + const openerTabId = request.openerTabId + ? browserWorkspaceTabId(request.openerTabId) + : undefined + openBrowserTab(request.url, { + activate: request.activate, + openerTabId, + }) } ), ]) diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx index f2888a1ed7..dd74ec78ba 100644 --- a/src/contexts/workspace-context.test.tsx +++ b/src/contexts/workspace-context.test.tsx @@ -3281,6 +3281,18 @@ describe("browser tabs", () => { open-bg </button> <button onClick={() => openBrowserTab("not a url")}>open-bad</button> + <button + onClick={() => { + const opener = fileTabs.find((t) => t.kind === "browser") + if (!opener) return + openBrowserTab("https://example.com/next", { + activate: false, + openerTabId: opener.id, + }) + }} + > + open-next + </button> <button onClick={() => { const opener = fileTabs.find((t) => t.kind === "browser") @@ -3381,6 +3393,26 @@ describe("browser tabs", () => { expect(screen.getByTestId("active").textContent).toBe(tabs[1].id) }) + it("inserts a modifier-click tab right after its opener without activating it", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + act(() => screen.getByText("open-bg").click()) + act(() => screen.getByText("open-next").click()) + const tabs = readTabs() + expect(tabs.map((t) => t.url)).toEqual([ + "https://example.com/docs#top", + "https://example.com/next", + "http://localhost:3000/", + ]) + expect(tabs[1].opener).toBe(tabs[0].id) + // Like a browser: the page the user is reading stays in front. + expect(screen.getByTestId("active").textContent).toBe(tabs[0].id) + }) + it("closes a browser tab without a dirty prompt and moves activation to a neighbour", () => { const confirmSpy = vi.spyOn(window, "confirm") render( diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 46556a4c66..b80407d530 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -233,9 +233,11 @@ interface WorkspaceActionsValue { // per URL (fragment ignored): a second open activates the existing tab. // Returns the tab id, or null when the URL does not parse. The native // surface is created by the tab's view when it mounts, not here. + // `openerTabId` (a workspace tab id) places the new tab right after that + // tab, the way a ⌘/Ctrl-click lands next to the page it came from. openBrowserTab: ( url: string, - options?: { folderId?: number; activate?: boolean } + options?: { folderId?: number; activate?: boolean; openerTabId?: string } ) => string | null // Register a tab for a webview the BACKEND already created — a popup the // page opened that the host adopted. `backendTabId` is the backend's id @@ -692,7 +694,10 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { ) const openBrowserTab = useCallback( - (url: string, options?: { folderId?: number; activate?: boolean }) => { + ( + url: string, + options?: { folderId?: number; activate?: boolean; openerTabId?: string } + ) => { const normalized = normalizeUrlForDedupe(url) if (!normalized) return null const existing = fileTabsRef.current.find( @@ -704,22 +709,38 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { if (options?.activate !== false) activateTab(existing.id) return existing.id } + const opener = options?.openerTabId + ? fileTabsRef.current.find((tab) => tab.id === options.openerTabId) + : undefined const record = browserTabRecord( crypto.randomUUID(), url, - options?.folderId ?? activeFolderRef.current?.id ?? null, - null + options?.folderId ?? + opener?.folderId ?? + activeFolderRef.current?.id ?? + null, + opener?.id ?? null ) + const insert = (prev: FileWorkspaceTab[]) => { + if (prev.some((tab) => tab.id === record.id)) return prev + const idx = opener ? prev.findIndex((tab) => tab.id === opener.id) : -1 + if (idx < 0) return [...prev, record] + const next = [...prev] + next.splice(idx + 1, 0, record) + return next + } if (options?.activate === false) { - setFileTabs((prev) => - prev.some((tab) => tab.id === record.id) ? prev : [...prev, record] - ) + setFileTabs(insert) + } else if (opener) { + setFileTabs(insert) + setActiveFileTabId(record.id) + activateFilePane() } else { seedLoadingTab(record) } return record.id }, - [activateTab, browserTabRecord, seedLoadingTab] + [activateFilePane, activateTab, browserTabRecord, seedLoadingTab] ) const adoptBrowserTab = useCallback( diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index d622940e0f..2f3653e3b5 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -93,6 +93,8 @@ export interface BrowserOpenRequestPayload { source: string activate: boolean ownerWindow: string | null + /** Backend id of the tab the request came from (modifier-click), if any. */ + openerTabId: string | null } export const BROWSER_OPEN_REQUEST_EVENT = "browser://open-request" From 4317fefab0cc400983e6922e8e964db963d4a4ad Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 21:08:02 +0800 Subject: [PATCH 11/79] feat(browser): settings section, real DevTools/surface preferences and clear browsing data Adds the Built-in browser section to General settings: default target per link source, web inspector switch, surface override and a confirmed clear of cookies, caches and site storage (new browser_clear_data command; on macOS straight at the shared WKWebsiteDataStore so it works with no tab open). The inspector and surface preferences existed but nothing read them: the surface host now passes both when it creates a tab, the child builder honours devtools per tab and popups inherit the opener's value. Also: the pop-up notice bar can open the blocked address as a tab next to its opener, and the file header shows a browser tab's live title. --- src-tauri/Cargo.toml | 4 +- src-tauri/src/browser/registry.rs | 12 +- src-tauri/src/browser/shim/macos.rs | 21 +- src-tauri/src/browser/smoke.rs | 7 + src-tauri/src/browser/surface.rs | 8 + src-tauri/src/browser/surface_child.rs | 14 +- src-tauri/src/browser/surface_window.rs | 2 + src-tauri/src/commands/browser.rs | 63 +++++ src-tauri/src/lib.rs | 1 + .../browser/browser-status-layer.test.tsx | 96 +++++++ .../browser/browser-status-layer.tsx | 19 ++ .../browser/browser-surface-host.tsx | 6 + .../files/file-workspace-header.tsx | 15 +- .../settings/browser-settings.test.tsx | 121 +++++++++ src/components/settings/browser-settings.tsx | 249 ++++++++++++++++++ .../settings/general-settings.test.tsx | 1 + src/components/settings/general-settings.tsx | 3 + src/i18n/messages/ar.json | 30 +++ src/i18n/messages/de.json | 30 +++ src/i18n/messages/en.json | 30 +++ src/i18n/messages/es.json | 30 +++ src/i18n/messages/fr.json | 30 +++ src/i18n/messages/ja.json | 30 +++ src/i18n/messages/ko.json | 30 +++ src/i18n/messages/pt.json | 30 +++ src/i18n/messages/zh-CN.json | 30 +++ src/i18n/messages/zh-TW.json | 30 +++ src/lib/browser/browser-api.ts | 8 + 28 files changed, 938 insertions(+), 12 deletions(-) create mode 100644 src/components/browser/browser-status-layer.test.tsx create mode 100644 src/components/settings/browser-settings.test.tsx create mode 100644 src/components/settings/browser-settings.tsx diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 358bb1a72d..1a7c66056f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -181,8 +181,8 @@ mac-notification-sys = "0.6" # with the handles wry hands out. objc2 = "0.6" block2 = "0.6" -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread"] } -objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet"] } +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation", "WKWebsiteDataStore"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index cb7833541e..e3635a0e7a 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -33,16 +33,26 @@ pub struct BrowserTab { /// shown again after being hidden. pub last_bounds: Bounds, pub visible: bool, + /// Whether the surface was built with the inspector enabled (a user + /// preference read at open time). Popups inherit their opener's value. + pub devtools: bool, pub gestures: VecDeque<GestureRecord>, } impl BrowserTab { - pub fn new(state: BrowserTabState, surface: BrowserSurface, bounds: Bounds, visible: bool) -> Self { + pub fn new( + state: BrowserTabState, + surface: BrowserSurface, + bounds: Bounds, + visible: bool, + devtools: bool, + ) -> Self { Self { state, surface, last_bounds: bounds, visible, + devtools, gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), } } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 9d141b4807..f93e7b75a2 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -14,10 +14,10 @@ use objc2::rc::Retained; use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject}; use objc2::{define_class, msg_send, sel, DeclaredClass, MainThreadMarker, MainThreadOnly}; use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; -use objc2_foundation::{ns_string, NSDictionary, NSError, NSString}; +use objc2_foundation::{ns_string, NSDate, NSDictionary, NSError, NSString}; use objc2_web_kit::{ WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKSnapshotConfiguration, - WKUserContentController, WKUserScript, WKUserScriptInjectionTime, + WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebsiteDataStore, }; use tauri_runtime_wry::wry::{self, WebViewExtMacOS}; @@ -259,6 +259,23 @@ pub fn stop_loading(webview: &wry::WebView) { /// Identity of the platform webview behind a wry `WebView`, matching the /// `source` a message sink receives. +/// Remove every kind of website data (cookies, caches, storage, …) from the +/// default data store — the one all browser tabs share, whether or not any +/// tab is open right now. `done` runs on the main thread once WebKit has +/// finished. +pub fn clear_default_data_store(done: impl Fn() + 'static) -> Result<(), String> { + let mtm = MainThreadMarker::new().ok_or("not on the main thread")?; + // SAFETY: main thread; WebKit owns every object handed back. + unsafe { + let store = WKWebsiteDataStore::defaultDataStore(mtm); + let types = WKWebsiteDataStore::allWebsiteDataTypes(mtm); + let since = NSDate::dateWithTimeIntervalSince1970(0.0); + let handler = RcBlock::new(done); + store.removeDataOfTypes_modifiedSince_completionHandler(&types, &since, &handler); + } + Ok(()) +} + pub fn webview_pointer(webview: &wry::WebView) -> usize { Retained::as_ptr(&webview.webview()) as usize } diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 66748fe9e3..1d25d7177f 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -232,6 +232,7 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .and_then(Value::as_bool) .unwrap_or(false), surface, + devtools: cmd.get("devtools").and_then(Value::as_bool).unwrap_or(false), }, ) .map_err(err_string)?; @@ -361,6 +362,12 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .collect(); Ok(Value::Array(gestures)) } + "browser_clear_data" => { + browser_commands::clear_data_core(app, ®istry) + .await + .map_err(err_string)?; + Ok(Value::Null) + } // Ask the frontend to open a URL as a browser tab (exercises the real // tab record → surface host → browser_open_tab path). "frontend_open" => { diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index 5884d01d52..8c63d17c1c 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -189,4 +189,12 @@ impl BrowserSurface { pub fn set_zoom(&self, factor: f64) -> Result<(), SurfaceError> { per_surface!(self, child: |c| Ok(c.zoom(factor)?), window: |w| Ok(w.set_zoom(factor)?)) } + + /// Wipe cookies, caches and storage. Every tab shares one data store, so + /// clearing through any surface clears them all. + pub fn clear_browsing_data(&self) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.clear_all_browsing_data()?), + window: |w| Ok(w.clear_all_browsing_data()?)) + } } diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index d2294f2bd5..df087b56c6 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -368,6 +368,7 @@ type OpenerConfiguration = (); /// Main thread only. Builds the child webview at `bounds` with every hook /// attached and **no URL**: a regular tab is navigated by the caller once the /// page channel is installed, a popup is navigated by the engine itself. +#[allow(clippy::too_many_arguments)] fn build_child( app: &AppHandle, owner: &WebviewWindow, @@ -375,6 +376,7 @@ fn build_child( label: &str, bounds: Bounds, visible: bool, + devtools: bool, configuration: Option<OpenerConfiguration>, ) -> Result<wry::WebView, String> { let nav_id = tab_id.to_string(); @@ -386,7 +388,7 @@ fn build_child( .with_bounds(rect(bounds)) .with_visible(visible) .with_focused(false) - .with_devtools(true) + .with_devtools(devtools) .with_hotkeys_zoom(true) .with_navigation_handler(move |url| { let allowed = Url::parse(&url) @@ -455,6 +457,7 @@ pub fn create( label: &str, bounds: Bounds, background: bool, + devtools: bool, ) -> Result<ChildHandle, ChildError> { let handle = ChildHandle { tab_id: tab_id.to_string(), @@ -466,7 +469,7 @@ pub fn create( let id = tab_id.to_string(); let label = label.to_string(); run_on_main(&app.clone(), move || -> Result<(), String> { - let webview = build_child(&app, &owner, &id, &label, bounds, !background, None)?; + let webview = build_child(&app, &owner, &id, &label, bounds, !background, devtools, None)?; SURFACES.with(|s| s.borrow_mut().insert(id, webview)); Ok(()) })? @@ -523,11 +526,11 @@ fn new_window_handler( let seq = POPUP_SEQ.fetch_add(1, Ordering::SeqCst) + 1; let tab_id = format!("{opener_tab_id}-p{seq}"); let label = super::tab_label(&tab_id); - let bounds = registry - .update(&opener_tab_id, |tab| tab.last_bounds) + let (bounds, devtools) = registry + .update(&opener_tab_id, |tab| (tab.last_bounds, tab.devtools)) .unwrap_or_default(); let configuration = features.opener.target_configuration.clone(); - let webview = match build_child(&app, &owner, &tab_id, &label, bounds, true, Some(configuration)) { + let webview = match build_child(&app, &owner, &tab_id, &label, bounds, true, devtools, Some(configuration)) { Ok(webview) => webview, Err(err) => { tracing::warn!("[browser] popup webview creation failed: {err}"); @@ -578,6 +581,7 @@ fn new_window_handler( BrowserSurface::Child(handle), bounds, true, + devtools, )) { tracing::warn!("[browser] popup registry insert failed: {err}"); SURFACES.with(|s| s.borrow_mut().remove(&tab_id)); diff --git a/src-tauri/src/browser/surface_window.rs b/src-tauri/src/browser/surface_window.rs index 109750578a..c162249ea9 100644 --- a/src-tauri/src/browser/surface_window.rs +++ b/src-tauri/src/browser/surface_window.rs @@ -17,6 +17,7 @@ pub fn create( label: &str, title: &str, background: bool, + devtools: bool, ) -> tauri::Result<WebviewWindow> { let blank = Url::parse("about:blank").expect("static url"); let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::External(blank)) @@ -24,6 +25,7 @@ pub fn create( .inner_size(1100.0, 760.0) .min_inner_size(480.0, 320.0) .focused(!background) + .devtools(devtools) .on_navigation(policy::navigation_allowed) .on_page_load({ let app = app.clone(); diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 36c9b6a505..5e95e73810 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -118,6 +118,8 @@ pub struct OpenTabParams { pub bounds: Bounds, pub background: bool, pub surface: SurfaceChoice, + /// Build the surface with the web inspector available (user preference). + pub devtools: bool, } pub fn open_tab_core( @@ -149,6 +151,7 @@ pub fn open_tab_core( &label, params.bounds, params.background, + params.devtools, ) .map_err(|e| window_err("Failed to create browser webview", e))?, ), @@ -160,6 +163,7 @@ pub fn open_tab_core( &label, &origin_title(&url), params.background, + params.devtools, ) .map_err(|e| window_err("Failed to create browser window", e))?, )), @@ -188,6 +192,7 @@ pub fn open_tab_core( surface.clone(), params.bounds, !params.background, + params.devtools, )) { let _ = surface.close(); return Err(err); @@ -227,6 +232,54 @@ pub fn open_tab_core( Ok(state) } +/// Wipe cookies, caches and every other kind of stored site data. All tabs +/// share one persistent store, so this is app-wide; open pages keep running +/// (nothing is reloaded, as in a browser). +pub async fn clear_data_core(app: &AppHandle, registry: &BrowserRegistry) -> Result<(), AppCommandError> { + #[cfg(target_os = "macos")] + { + // Straight at the shared default store: works with no tab open and + // reports completion, which a surface's `clear_all_browsing_data` + // cannot. + let _ = registry; + let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + let finish = move |result: Result<(), String>| { + if let Some(tx) = tx.lock().unwrap_or_else(|p| p.into_inner()).take() { + let _ = tx.send(result); + } + }; + app.run_on_main_thread(move || { + let on_done = finish.clone(); + if let Err(err) = crate::browser::shim::macos::clear_default_data_store(move || on_done(Ok(()))) { + finish(Err(err)); + } + }) + .map_err(|e| window_err("Failed to clear browsing data", e))?; + match tokio::time::timeout(std::time::Duration::from_secs(15), rx).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(err))) => Err(window_err("Failed to clear browsing data", err)), + Ok(Err(_)) => Err(window_err("Failed to clear browsing data", "the request was dropped")), + Err(_) => Err(window_err("Failed to clear browsing data", "timed out waiting for WebKit")), + } + } + #[cfg(not(target_os = "macos"))] + { + // Until the Windows / Linux shims land, clearing goes through a live + // surface (they all share the store); with none open there is nothing + // to call into. + let _ = app; + let Some(state) = registry.list().into_iter().next() else { + return Err(AppCommandError::invalid_input( + "open a page in the built-in browser first, then clear its data", + )); + }; + surface_of(registry, &state.tab_id)? + .clear_browsing_data() + .map_err(|e| window_err("Failed to clear browsing data", e)) + } +} + fn surface_of(registry: &BrowserRegistry, tab_id: &str) -> Result<BrowserSurface, AppCommandError> { registry .surface(tab_id) @@ -383,6 +436,7 @@ pub async fn browser_open_tab( background: Option<bool>, surface: Option<SurfaceChoice>, folder_id: Option<i64>, + devtools: Option<bool>, ) -> Result<BrowserTabState, AppCommandError> { // Folder scoping is a frontend concern (tab strip grouping); the backend // only needs the owner window. @@ -397,10 +451,19 @@ pub async fn browser_open_tab( bounds, background: background.unwrap_or(false), surface: surface.unwrap_or_default(), + devtools: devtools.unwrap_or(false), }, ) } +#[tauri::command] +pub async fn browser_clear_data( + app: AppHandle, + registry: State<'_, BrowserRegistry>, +) -> Result<(), AppCommandError> { + clear_data_core(&app, ®istry).await +} + #[tauri::command] pub async fn browser_close( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 299ff03f7f..450db44e44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1205,6 +1205,7 @@ mod tauri_app { browser_commands::browser_stop, browser_commands::browser_get_state, browser_commands::browser_list_tabs, + browser_commands::browser_clear_data, conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, diff --git a/src/components/browser/browser-status-layer.test.tsx b/src/components/browser/browser-status-layer.test.tsx new file mode 100644 index 0000000000..0319462a22 --- /dev/null +++ b/src/components/browser/browser-status-layer.test.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + actions: null as null | { openBrowserTab: ReturnType<typeof vi.fn> }, +})) + +vi.mock("@/contexts/workspace-context", () => ({ + useOptionalWorkspaceActions: () => mocks.actions, +})) +vi.mock("@/lib/browser/browser-api", () => ({ browserReload: vi.fn() })) +vi.mock("@/lib/platform", () => ({ openUrl: vi.fn() })) + +import { BrowserNoticeBar } from "./browser-status-layer" +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import enMessages from "@/i18n/messages/en.json" +import { + resetBrowserTabStoreForTests, + setBrowserTabNotice, +} from "@/lib/browser/browser-tab-store" + +const tab = { + id: "browser:abc", + kind: "browser", + folderId: null, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: true, + readonly: true, + browser: { initialUrl: "https://example.com/", openerTabId: null }, +} as unknown as BrowserWorkspaceTab + +function renderBar() { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserNoticeBar tab={tab} state={null} /> + </NextIntlClientProvider> + ) +} + +beforeEach(() => { + resetBrowserTabStoreForTests() + mocks.actions = null +}) + +describe("BrowserNoticeBar", () => { + it("renders nothing without a notice or a remote host", () => { + const { container } = renderBar() + expect(container).toBeEmptyDOMElement() + }) + + it("offers to open a blocked pop-up as a tab next to its opener", () => { + const openBrowserTab = vi.fn() + mocks.actions = { openBrowserTab } + renderBar() + act(() => + setBrowserTabNotice(tab.id, { + kind: "popup-denied", + url: "https://accounts.example.com/login?x=1", + reason: "no-gesture", + }) + ) + expect( + screen.getByText(/Pop-up blocked: accounts\.example\.com/) + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Open anyway" })) + expect(openBrowserTab).toHaveBeenCalledWith( + "https://accounts.example.com/login?x=1", + { openerTabId: "browser:abc" } + ) + // Acting on the notice also dismisses it. + expect(screen.queryByText(/Pop-up blocked/)).not.toBeInTheDocument() + }) + + it("only reports the block outside the workspace providers", () => { + renderBar() + act(() => + setBrowserTabNotice(tab.id, { + kind: "popup-denied", + url: "https://example.org/", + reason: "blocked-scheme", + }) + ) + expect(screen.getByText(/Pop-up blocked/)).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Open anyway" }) + ).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })) + expect(screen.queryByText(/Pop-up blocked/)).not.toBeInTheDocument() + }) +}) diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index d64416d251..f3b79f3972 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -4,6 +4,7 @@ import { ExternalLink, RotateCw, ShieldAlert, X } from "lucide-react" import { useTranslations } from "next-intl" import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { useOptionalWorkspaceActions } from "@/contexts/workspace-context" import { browserReload } from "@/lib/browser/browser-api" import { setBrowserTabNotice, @@ -25,6 +26,9 @@ export function BrowserNoticeBar({ }) { const t = useTranslations("Browser.status") const notice = useBrowserTabNotice(tab.id) + // Null outside the workspace providers (the viewer drawer on a full-screen + // route); the bar then only reports the block. + const actions = useOptionalWorkspaceActions() if (!notice && !state?.remoteHost) return null return ( <div className="flex flex-col"> @@ -46,6 +50,21 @@ export function BrowserNoticeBar({ ? ` · ${t("popupDeniedBlockedScheme")}` : ""} </span> + {/* Opens the blocked address as a plain tab: the page's own + `window.open` is gone, so there is no opener to preserve — the + same trade a browser's "show blocked pop-up" makes. */} + {actions ? ( + <button + type="button" + className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/8" + onClick={() => { + actions.openBrowserTab(notice.url, { openerTabId: tab.id }) + setBrowserTabNotice(tab.id, null) + }} + > + {t("popupOpenAnyway")} + </button> + ) : null} <button type="button" className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-primary/8" diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index ed58d538cb..b570828740 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -11,6 +11,7 @@ import { browserSetBounds, browserSetVisible, } from "@/lib/browser/browser-api" +import { getBrowserPrefs } from "@/lib/browser/browser-prefs" import { getBrowserTabState, setBrowserTabState, @@ -124,11 +125,16 @@ export function BrowserSurfaceHost({ const bounds = measure(el) lastBoundsRef.current = bounds lastVisibleRef.current = true + // Preferences are read once, here: a surface cannot change its inspector + // or its kind after it exists, so a settings change applies to new tabs. + const prefs = getBrowserPrefs() browserOpenTab({ tabId: backendId, url: tab.browser.initialUrl, bounds, folderId: tab.folderId, + surface: prefs.surfaceOverride, + devtools: prefs.devtools, }) .then((next) => { setBrowserTabState(next) diff --git a/src/components/files/file-workspace-header.tsx b/src/components/files/file-workspace-header.tsx index ddbb865e86..4f7106add3 100644 --- a/src/components/files/file-workspace-header.tsx +++ b/src/components/files/file-workspace-header.tsx @@ -9,6 +9,7 @@ import { useWorkspaceFileTabs, } from "@/contexts/workspace-context" import { FilePathBreadcrumb } from "@/components/files/file-path-breadcrumb" +import { useBrowserTabState } from "@/lib/browser/browser-tab-store" import { cn } from "@/lib/utils" /** @@ -26,9 +27,19 @@ export function FileWorkspaceHeader() { const { activeFileTab, activeFileTabId, previewFileTabIds } = useWorkspaceFileTabs() const { toggleFileTabPreview } = useWorkspaceActions() + // A browser tab's title follows the page (document.title); its record only + // ever knew the host it was opened with. + const browserState = useBrowserTabState( + activeFileTab?.kind === "browser" ? activeFileTab.id : null + ) if (!activeFileTab) return null + const displayTitle = + activeFileTab.kind === "browser" + ? browserState?.title || activeFileTab.title + : activeFileTab.title + const isDiff = activeFileTab.kind === "diff" || activeFileTab.kind === "rich-diff" const isDirty = @@ -64,9 +75,9 @@ export function FileWorkspaceHeader() { {isDiff || !activeFileTab.path ? ( <span className="truncate text-foreground/90" - title={activeFileTab.description ?? activeFileTab.title} + title={activeFileTab.description ?? displayTitle} > - {activeFileTab.title} + {displayTitle} {isDirty ? " *" : ""} </span> ) : ( diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx new file mode 100644 index 0000000000..2b09490a7e --- /dev/null +++ b/src/components/settings/browser-settings.test.tsx @@ -0,0 +1,121 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + browserClearData: vi.fn(), + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/browser/browser-api", () => ({ + browserClearData: mocks.browserClearData, +})) +vi.mock("@/lib/platform", () => ({ isDesktop: () => true })) +vi.mock("sonner", () => ({ toast: mocks.toast })) + +import { BrowserSettingsSection } from "./browser-settings" +import enMessages from "@/i18n/messages/en.json" +import { + getBrowserPrefs, + resetBrowserPrefsForTests, + setDefaultLinkTarget, +} from "@/lib/browser/browser-prefs" + +function renderSection() { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserSettingsSection /> + </NextIntlClientProvider> + ) +} + +/** The section arrives folded; every knob lives under the heading. */ +function expandSection() { + fireEvent.click(screen.getByRole("button", { name: "Built-in browser" })) +} + +beforeEach(() => { + resetBrowserPrefsForTests() + mocks.browserClearData.mockReset() + mocks.toast.success.mockReset() + mocks.toast.error.mockReset() +}) + +describe("BrowserSettingsSection", () => { + it("arrives folded and shows one picker per link source once open", () => { + renderSection() + expect( + screen.queryByRole("combobox", { name: "Conversation messages" }) + ).not.toBeInTheDocument() + + expandSection() + for (const source of [ + "Conversation messages", + "Tool results", + "Terminal", + "Editor", + "Notifications", + ]) { + expect(screen.getByRole("combobox", { name: source })).toHaveTextContent( + "Built-in browser" + ) + } + expect(screen.getByLabelText("Web inspector")).not.toBeChecked() + expect( + screen.getByRole("combobox", { name: "Tab surface" }) + ).toHaveTextContent("Automatic") + }) + + it("persists the inspector switch and follows a change made elsewhere", () => { + renderSection() + expandSection() + + fireEvent.click(screen.getByLabelText("Web inspector")) + expect(getBrowserPrefs().devtools).toBe(true) + expect(screen.getByLabelText("Web inspector")).toBeChecked() + + // The workspace window (the first-open toast) writes the same keys. + act(() => setDefaultLinkTarget("terminal", "system")) + expect( + screen.getByRole("combobox", { name: "Terminal" }) + ).toHaveTextContent("System browser") + expect( + screen.getByRole("combobox", { name: "Conversation messages" }) + ).toHaveTextContent("Built-in browser") + }) + + it("clears browsing data only after confirmation", async () => { + mocks.browserClearData.mockResolvedValue(undefined) + renderSection() + expandSection() + + fireEvent.click(screen.getByRole("button", { name: "Clear…" })) + expect(mocks.browserClearData).not.toHaveBeenCalled() + expect( + screen.getByRole("heading", { name: "Clear browsing data?" }) + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Clear" })) + await waitFor(() => expect(mocks.browserClearData).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(mocks.toast.success).toHaveBeenCalledWith("Browsing data cleared") + ) + }) + + it("keeps the dialog and reports the failure when clearing fails", async () => { + mocks.browserClearData.mockRejectedValue(new Error("WebKit said no")) + renderSection() + expandSection() + + fireEvent.click(screen.getByRole("button", { name: "Clear…" })) + fireEvent.click(screen.getByRole("button", { name: "Clear" })) + await waitFor(() => + expect(mocks.toast.error).toHaveBeenCalledWith( + "Could not clear browsing data: WebKit said no" + ) + ) + expect( + screen.getByRole("heading", { name: "Clear browsing data?" }) + ).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx new file mode 100644 index 0000000000..bfa9f8bec9 --- /dev/null +++ b/src/components/settings/browser-settings.tsx @@ -0,0 +1,249 @@ +"use client" + +/** + * Built-in browser settings: where links open by default (per source), whether + * browser tabs get the web inspector, which native surface hosts them, and a + * one-shot "clear browsing data". + * + * Preferences live in localStorage (`browser-prefs.ts`): written immediately, + * mirrored across windows through the storage event, so there is no Save + * button. The inspector and surface choices are read when a tab is created, + * which is why their hints say "from now on". Desktop only — in web mode every + * link goes to the system browser and none of this exists. + */ + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { AppWindow, Eraser, Globe, Link2, Wrench } from "lucide-react" +import { toast } from "sonner" + +import { SettingCard, SettingRow } from "@/components/shared/setting-card" +import { SettingsSection } from "@/components/shared/settings-section" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { toErrorMessage } from "@/lib/app-error" +import { browserClearData } from "@/lib/browser/browser-api" +import { + LINK_SOURCES, + setBrowserDevtools, + setBrowserSurfaceOverride, + setDefaultLinkTarget, + useBrowserPrefs, + type LinkSource, + type LinkTarget, + type SurfaceOverride, +} from "@/lib/browser/browser-prefs" +import { isDesktop } from "@/lib/platform" + +// Literal message keys per id — next-intl only resolves literal keys, so the +// lookup tables keep the rows data-driven without losing key checking. +const SOURCE_LABEL_KEYS = { + transcript: "sourceTranscript", + toolCard: "sourceToolCard", + terminal: "sourceTerminal", + editor: "sourceEditor", + notification: "sourceNotification", +} as const satisfies Record<LinkSource, string> + +const TARGETS: readonly LinkTarget[] = ["builtin", "system"] +const TARGET_LABEL_KEYS = { + builtin: "targetBuiltin", + system: "targetSystem", +} as const satisfies Record<LinkTarget, string> + +const SURFACES: readonly SurfaceOverride[] = ["auto", "child", "window"] +const SURFACE_LABEL_KEYS = { + auto: "surfaceAuto", + child: "surfaceChild", + window: "surfaceWindow", +} as const satisfies Record<SurfaceOverride, string> + +export function BrowserSettingsSection() { + const t = useTranslations("BrowserSettings") + const prefs = useBrowserPrefs() + // Folded on arrival like its neighbours: the General tab is a stack of + // sections, and this one is five pickers tall. + const [expanded, setExpanded] = useState(false) + const [confirmClear, setConfirmClear] = useState(false) + const [clearing, setClearing] = useState(false) + + if (!isDesktop()) return null + + const clear = async () => { + setClearing(true) + try { + await browserClearData() + toast.success(t("cleared")) + setConfirmClear(false) + } catch (error) { + toast.error(t("clearFailed", { message: toErrorMessage(error) })) + } finally { + setClearing(false) + } + } + + return ( + <SettingsSection + icon={Globe} + title={t("title")} + description={t("description")} + collapsible + open={expanded} + onOpenChange={setExpanded} + > + <SettingCard> + {/* One setting with five values, so one row whose control is the + list — not five rows repeating the same explanation. */} + <SettingRow + icon={Link2} + title={t("defaultTargetTitle")} + description={t("defaultTargetHint")} + > + <div className="space-y-1.5"> + {LINK_SOURCES.map((source) => { + const label = t(SOURCE_LABEL_KEYS[source]) + return ( + <div + key={source} + className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background px-3 py-2" + > + <span className="min-w-0 truncate text-sm">{label}</span> + <Select + value={prefs.defaultTarget[source]} + onValueChange={(value) => + setDefaultLinkTarget(source, value as LinkTarget) + } + > + <SelectTrigger + size="sm" + className="w-44 bg-background text-xs" + aria-label={label} + > + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {TARGETS.map((target) => ( + <SelectItem key={target} value={target}> + {t(TARGET_LABEL_KEYS[target])} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) + })} + </div> + </SettingRow> + </SettingCard> + + <SettingCard> + <SettingRow + icon={Wrench} + title={t("devtoolsTitle")} + description={t("devtoolsHint")} + htmlFor="browser-devtools" + control={ + <Switch + id="browser-devtools" + checked={prefs.devtools} + onCheckedChange={(enabled) => setBrowserDevtools(enabled)} + /> + } + /> + <SettingRow + icon={AppWindow} + title={t("surfaceTitle")} + description={t("surfaceHint")} + control={ + <Select + value={prefs.surfaceOverride} + onValueChange={(value) => + setBrowserSurfaceOverride(value as SurfaceOverride) + } + > + <SelectTrigger + size="sm" + className="w-44 bg-background text-xs" + aria-label={t("surfaceTitle")} + > + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {SURFACES.map((surface) => ( + <SelectItem key={surface} value={surface}> + {t(SURFACE_LABEL_KEYS[surface])} + </SelectItem> + ))} + </SelectContent> + </Select> + } + /> + <SettingRow + icon={Eraser} + title={t("clearTitle")} + description={t("clearHint")} + control={ + <Button + type="button" + variant="outline" + size="sm" + className="bg-background" + onClick={() => setConfirmClear(true)} + > + {t("clearAction")} + </Button> + } + /> + </SettingCard> + + <AlertDialog + open={confirmClear} + onOpenChange={(open) => { + if (!clearing) setConfirmClear(open) + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{t("clearConfirmTitle")}</AlertDialogTitle> + <AlertDialogDescription> + {t("clearConfirmDescription")} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={clearing}> + {t("cancel")} + </AlertDialogCancel> + {/* Stays open until the backend answers, so a failure toast has + the dialog it belongs to still on screen. */} + <AlertDialogAction + disabled={clearing} + onClick={(event) => { + event.preventDefault() + void clear() + }} + > + {t("clearConfirmAction")} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </SettingsSection> + ) +} diff --git a/src/components/settings/general-settings.test.tsx b/src/components/settings/general-settings.test.tsx index 9e511ff698..b6631e7b09 100644 --- a/src/components/settings/general-settings.test.tsx +++ b/src/components/settings/general-settings.test.tsx @@ -133,6 +133,7 @@ describe("GeneralSettings", () => { "Notification sounds", "Multi-Agent Collaboration", "In-conversation tools", + "Built-in browser", ]) { expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument() } diff --git a/src/components/settings/general-settings.tsx b/src/components/settings/general-settings.tsx index 913e8eac3b..728c7bf547 100644 --- a/src/components/settings/general-settings.tsx +++ b/src/components/settings/general-settings.tsx @@ -45,6 +45,7 @@ import { DesktopNotificationSettingsSection } from "@/components/settings/deskto import { NotificationSoundSettingsSection } from "@/components/settings/notification-sound-settings" import { DelegationSettingsSection } from "@/components/settings/delegation-settings" import { AgentToolsSettingsSection } from "@/components/settings/agent-tools-settings" +import { BrowserSettingsSection } from "@/components/settings/browser-settings" const TERMINAL_SHELL_OPTION_SYSTEM = "system" const TERMINAL_SHELL_OPTION_CUSTOM = "custom" @@ -420,6 +421,8 @@ export function GeneralSettings() { <DelegationSettingsSection /> <AgentToolsSettingsSection /> + + <BrowserSettingsSection /> </div> </ScrollArea> ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b740e9d404..3b327e1924 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4725,6 +4725,35 @@ "toneAlert": "تنبيه", "toneDescend": "نغمة هابطة" }, + "BrowserSettings": { + "title": "المتصفح المدمج", + "description": "يمكن عرض صفحات الويب المفتوحة من المحادثات ونتائج الأدوات والطرفية كعلامات تبويب بجانب ملفاتك دون مغادرة التطبيق.", + "defaultTargetTitle": "مكان فتح الروابط", + "defaultTargetHint": "يُضبط لكل مصدر على حدة. اضغط ⌘/Ctrl مع النقر على رابط لاستخدام الخيار الآخر لهذه المرة فقط.", + "sourceTranscript": "رسائل المحادثة", + "sourceToolCard": "نتائج الأدوات", + "sourceTerminal": "الطرفية", + "sourceEditor": "المحرر", + "sourceNotification": "الإشعارات", + "targetBuiltin": "المتصفح المدمج", + "targetSystem": "متصفح النظام", + "devtoolsTitle": "مفتش الويب", + "devtoolsHint": "السماح بفحص العناصر في علامات تبويب المتصفح. يسري على علامات التبويب التي تُفتح من الآن فصاعدًا.", + "surfaceTitle": "طريقة عرض علامات التبويب", + "surfaceHint": "كيفية استضافة الصفحات. أبقِ الخيار على «تلقائي» ما لم تتعطل علامات التبويب المضمّنة على هذا النظام. يسري على علامات التبويب التي تُفتح من الآن فصاعدًا.", + "surfaceAuto": "تلقائي", + "surfaceChild": "مضمّنة في مساحة العمل", + "surfaceWindow": "نافذة منفصلة", + "clearTitle": "بيانات التصفح", + "clearHint": "ملفات تعريف الارتباط وذاكرة التخزين المؤقت وتخزين المواقع المشتركة بين جميع علامات تبويب المتصفح. يؤدي المسح إلى تسجيل خروجك من المواقع.", + "clearAction": "مسح…", + "clearConfirmTitle": "هل تريد مسح بيانات التصفح؟", + "clearConfirmDescription": "ستُزال ملفات تعريف الارتباط وذاكرة التخزين المؤقت وتخزين المواقع الخاصة بالمتصفح المدمج. تبقى علامات التبويب المفتوحة كما هي، لكن سيتم تسجيل خروجك من المواقع.", + "clearConfirmAction": "مسح", + "cancel": "إلغاء", + "cleared": "تم مسح بيانات التصفح", + "clearFailed": "تعذّر مسح بيانات التصفح: {message}" + }, "LogsSettings": { "loading": "جارٍ التحميل…", "sectionTitle": "سجلات التشغيل", @@ -5776,6 +5805,7 @@ "popupDenied": "تم حظر نافذة منبثقة: {host}", "popupDeniedNoGesture": "لم تُفتح بنقرة", "popupDeniedBlockedScheme": "نوع العنوان غير مسموح به", + "popupOpenAnyway": "فتح على أي حال", "dismiss": "إغلاق", "errorDns": "تعذّر العثور على هذا الموقع", "errorTls": "هذا الاتصال غير آمن", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 94b7f3ced2..3b72515567 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4725,6 +4725,35 @@ "toneAlert": "Alarm", "toneDescend": "Absteigend" }, + "BrowserSettings": { + "title": "Integrierter Browser", + "description": "Webseiten aus Unterhaltungen, Werkzeugergebnissen und dem Terminal können als Tabs neben deinen Dateien erscheinen, ohne die App zu verlassen.", + "defaultTargetTitle": "Wo Links geöffnet werden", + "defaultTargetHint": "Je Quelle einstellbar. ⌘/Strg+Klick auf einen Link verwendet einmalig die andere Variante.", + "sourceTranscript": "Unterhaltungsnachrichten", + "sourceToolCard": "Werkzeugergebnisse", + "sourceTerminal": "Terminal", + "sourceEditor": "Editor", + "sourceNotification": "Benachrichtigungen", + "targetBuiltin": "Integrierter Browser", + "targetSystem": "Systembrowser", + "devtoolsTitle": "Web-Inspektor", + "devtoolsHint": "Erlaubt „Element untersuchen“ in Browser-Tabs. Gilt für ab jetzt geöffnete Tabs.", + "surfaceTitle": "Tab-Darstellung", + "surfaceHint": "Wie Seiten eingebettet werden. Auf „Automatisch“ belassen, außer eingebettete Tabs verhalten sich auf diesem System fehlerhaft. Gilt für ab jetzt geöffnete Tabs.", + "surfaceAuto": "Automatisch", + "surfaceChild": "Im Arbeitsbereich eingebettet", + "surfaceWindow": "Eigenes Fenster", + "clearTitle": "Browserdaten", + "clearHint": "Cookies, Caches und Website-Speicher, die alle Browser-Tabs teilen. Beim Löschen wirst du auf Websites abgemeldet.", + "clearAction": "Löschen…", + "clearConfirmTitle": "Browserdaten löschen?", + "clearConfirmDescription": "Cookies, Caches und Website-Speicher des integrierten Browsers werden entfernt. Offene Tabs bleiben geöffnet, du wirst aber auf Websites abgemeldet.", + "clearConfirmAction": "Löschen", + "cancel": "Abbrechen", + "cleared": "Browserdaten gelöscht", + "clearFailed": "Browserdaten konnten nicht gelöscht werden: {message}" + }, "LogsSettings": { "loading": "Wird geladen…", "sectionTitle": "Laufzeitprotokolle", @@ -5776,6 +5805,7 @@ "popupDenied": "Pop-up blockiert: {host}", "popupDeniedNoGesture": "nicht durch einen Klick geöffnet", "popupDeniedBlockedScheme": "Adresstyp nicht erlaubt", + "popupOpenAnyway": "Trotzdem öffnen", "dismiss": "Schließen", "errorDns": "Diese Website wurde nicht gefunden", "errorTls": "Diese Verbindung ist nicht sicher", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6fcde524b4..ced38ebc63 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4725,6 +4725,35 @@ "toneAlert": "Alert", "toneDescend": "Descending" }, + "BrowserSettings": { + "title": "Built-in browser", + "description": "Web pages opened from conversations, tool results and the terminal can appear as tabs next to your files instead of leaving the app.", + "defaultTargetTitle": "Where links open", + "defaultTargetHint": "Set per source. ⌘/Ctrl-click a link to use the other one just this once.", + "sourceTranscript": "Conversation messages", + "sourceToolCard": "Tool results", + "sourceTerminal": "Terminal", + "sourceEditor": "Editor", + "sourceNotification": "Notifications", + "targetBuiltin": "Built-in browser", + "targetSystem": "System browser", + "devtoolsTitle": "Web inspector", + "devtoolsHint": "Allow Inspect Element in browser tabs. Applies to tabs opened from now on.", + "surfaceTitle": "Tab surface", + "surfaceHint": "How pages are hosted. Keep Automatic unless embedded tabs misbehave on this system. Applies to tabs opened from now on.", + "surfaceAuto": "Automatic", + "surfaceChild": "Embedded in the workspace", + "surfaceWindow": "Separate window", + "clearTitle": "Browsing data", + "clearHint": "Cookies, caches and site storage shared by every browser tab. Clearing signs you out of sites.", + "clearAction": "Clear…", + "clearConfirmTitle": "Clear browsing data?", + "clearConfirmDescription": "Cookies, caches and site storage of the built-in browser will be removed. Open tabs stay open, but you will be signed out of sites.", + "clearConfirmAction": "Clear", + "cancel": "Cancel", + "cleared": "Browsing data cleared", + "clearFailed": "Could not clear browsing data: {message}" + }, "LogsSettings": { "loading": "Loading…", "sectionTitle": "Runtime Logs", @@ -5776,6 +5805,7 @@ "popupDenied": "Pop-up blocked: {host}", "popupDeniedNoGesture": "opened without a click", "popupDeniedBlockedScheme": "address type not allowed", + "popupOpenAnyway": "Open anyway", "dismiss": "Dismiss", "errorDns": "This site can't be found", "errorTls": "This connection is not secure", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5813af0f9d..fd5b4ee293 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4725,6 +4725,35 @@ "toneAlert": "Alerta", "toneDescend": "Descendente" }, + "BrowserSettings": { + "title": "Navegador integrado", + "description": "Las páginas web abiertas desde conversaciones, resultados de herramientas y la terminal pueden mostrarse como pestañas junto a tus archivos, sin salir de la aplicación.", + "defaultTargetTitle": "Dónde se abren los enlaces", + "defaultTargetHint": "Se configura por origen. Haz ⌘/Ctrl+clic en un enlace para usar la otra opción solo esta vez.", + "sourceTranscript": "Mensajes de la conversación", + "sourceToolCard": "Resultados de herramientas", + "sourceTerminal": "Terminal", + "sourceEditor": "Editor", + "sourceNotification": "Notificaciones", + "targetBuiltin": "Navegador integrado", + "targetSystem": "Navegador del sistema", + "devtoolsTitle": "Inspector web", + "devtoolsHint": "Permite Inspeccionar elemento en las pestañas del navegador. Se aplica a las pestañas abiertas a partir de ahora.", + "surfaceTitle": "Superficie de las pestañas", + "surfaceHint": "Cómo se alojan las páginas. Mantén Automático salvo que las pestañas incrustadas fallen en este sistema. Se aplica a las pestañas abiertas a partir de ahora.", + "surfaceAuto": "Automático", + "surfaceChild": "Incrustada en el espacio de trabajo", + "surfaceWindow": "Ventana independiente", + "clearTitle": "Datos de navegación", + "clearHint": "Cookies, cachés y almacenamiento de sitios compartidos por todas las pestañas del navegador. Al borrarlos se cerrará tu sesión en los sitios.", + "clearAction": "Borrar…", + "clearConfirmTitle": "¿Borrar los datos de navegación?", + "clearConfirmDescription": "Se eliminarán las cookies, las cachés y el almacenamiento de sitios del navegador integrado. Las pestañas abiertas seguirán abiertas, pero se cerrará tu sesión en los sitios.", + "clearConfirmAction": "Borrar", + "cancel": "Cancelar", + "cleared": "Datos de navegación borrados", + "clearFailed": "No se pudieron borrar los datos de navegación: {message}" + }, "LogsSettings": { "loading": "Cargando…", "sectionTitle": "Registros de ejecución", @@ -5776,6 +5805,7 @@ "popupDenied": "Ventana emergente bloqueada: {host}", "popupDeniedNoGesture": "no se abrió con un clic", "popupDeniedBlockedScheme": "tipo de dirección no permitido", + "popupOpenAnyway": "Abrir de todos modos", "dismiss": "Cerrar", "errorDns": "No se encuentra este sitio", "errorTls": "Esta conexión no es segura", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7f5f975c8d..c91ecbd817 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4725,6 +4725,35 @@ "toneAlert": "Alerte", "toneDescend": "Descendant" }, + "BrowserSettings": { + "title": "Navigateur intégré", + "description": "Les pages web ouvertes depuis les conversations, les résultats d’outils et le terminal peuvent s’afficher comme onglets à côté de vos fichiers, sans quitter l’application.", + "defaultTargetTitle": "Où s’ouvrent les liens", + "defaultTargetHint": "Réglable par source. ⌘/Ctrl+clic sur un lien utilise l’autre option pour cette fois seulement.", + "sourceTranscript": "Messages de la conversation", + "sourceToolCard": "Résultats d’outils", + "sourceTerminal": "Terminal", + "sourceEditor": "Éditeur", + "sourceNotification": "Notifications", + "targetBuiltin": "Navigateur intégré", + "targetSystem": "Navigateur du système", + "devtoolsTitle": "Inspecteur web", + "devtoolsHint": "Autorise « Inspecter l’élément » dans les onglets du navigateur. S’applique aux onglets ouverts à partir de maintenant.", + "surfaceTitle": "Surface des onglets", + "surfaceHint": "Comment les pages sont hébergées. Laissez Automatique sauf si les onglets intégrés fonctionnent mal sur ce système. S’applique aux onglets ouverts à partir de maintenant.", + "surfaceAuto": "Automatique", + "surfaceChild": "Intégrée à l’espace de travail", + "surfaceWindow": "Fenêtre séparée", + "clearTitle": "Données de navigation", + "clearHint": "Cookies, caches et stockage des sites partagés par tous les onglets du navigateur. Leur effacement vous déconnecte des sites.", + "clearAction": "Effacer…", + "clearConfirmTitle": "Effacer les données de navigation ?", + "clearConfirmDescription": "Les cookies, caches et stockage des sites du navigateur intégré seront supprimés. Les onglets ouverts le restent, mais vous serez déconnecté des sites.", + "clearConfirmAction": "Effacer", + "cancel": "Annuler", + "cleared": "Données de navigation effacées", + "clearFailed": "Impossible d’effacer les données de navigation : {message}" + }, "LogsSettings": { "loading": "Chargement…", "sectionTitle": "Journaux d'exécution", @@ -5776,6 +5805,7 @@ "popupDenied": "Fenêtre contextuelle bloquée : {host}", "popupDeniedNoGesture": "ouverte sans clic", "popupDeniedBlockedScheme": "type d'adresse non autorisé", + "popupOpenAnyway": "Ouvrir quand même", "dismiss": "Fermer", "errorDns": "Ce site est introuvable", "errorTls": "Cette connexion n'est pas sécurisée", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 3d6ef0575a..fbc120c9cc 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4725,6 +4725,35 @@ "toneAlert": "アラート", "toneDescend": "下降音" }, + "BrowserSettings": { + "title": "内蔵ブラウザ", + "description": "会話、ツールの結果、ターミナルから開いたウェブページを、アプリを離れずにファイルの隣のタブとして表示できます。", + "defaultTargetTitle": "リンクを開く場所", + "defaultTargetHint": "発生元ごとに設定します。⌘/Ctrl を押しながらクリックすると、その一回だけもう一方で開きます。", + "sourceTranscript": "会話メッセージ", + "sourceToolCard": "ツールの結果", + "sourceTerminal": "ターミナル", + "sourceEditor": "エディタ", + "sourceNotification": "通知", + "targetBuiltin": "内蔵ブラウザ", + "targetSystem": "システムのブラウザ", + "devtoolsTitle": "Web インスペクタ", + "devtoolsHint": "ブラウザタブで「要素の詳細を表示」を許可します。これ以降に開くタブに適用されます。", + "surfaceTitle": "タブの表示方式", + "surfaceHint": "ページの表示方法です。埋め込みタブがこの環境で正しく動作しない場合を除き「自動」のままにしてください。これ以降に開くタブに適用されます。", + "surfaceAuto": "自動", + "surfaceChild": "ワークスペースに埋め込む", + "surfaceWindow": "別ウィンドウ", + "clearTitle": "閲覧データ", + "clearHint": "すべてのブラウザタブで共有される Cookie、キャッシュ、サイトデータ。消去すると各サイトからサインアウトされます。", + "clearAction": "消去…", + "clearConfirmTitle": "閲覧データを消去しますか?", + "clearConfirmDescription": "内蔵ブラウザの Cookie、キャッシュ、サイトデータを削除します。開いているタブはそのまま残りますが、各サイトからサインアウトされます。", + "clearConfirmAction": "消去", + "cancel": "キャンセル", + "cleared": "閲覧データを消去しました", + "clearFailed": "閲覧データを消去できませんでした: {message}" + }, "LogsSettings": { "loading": "読み込み中…", "sectionTitle": "実行ログ", @@ -5776,6 +5805,7 @@ "popupDenied": "ポップアップをブロックしました: {host}", "popupDeniedNoGesture": "クリックによる操作ではありません", "popupDeniedBlockedScheme": "許可されていないアドレス形式です", + "popupOpenAnyway": "それでも開く", "dismiss": "閉じる", "errorDns": "このサイトは見つかりません", "errorTls": "この接続は安全ではありません", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0ed5ac3864..d553b7a263 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4725,6 +4725,35 @@ "toneAlert": "알림", "toneDescend": "하강음" }, + "BrowserSettings": { + "title": "내장 브라우저", + "description": "대화, 도구 결과, 터미널에서 연 웹 페이지를 앱을 벗어나지 않고 파일 옆의 탭으로 표시할 수 있습니다.", + "defaultTargetTitle": "링크를 여는 위치", + "defaultTargetHint": "출처별로 설정합니다. ⌘/Ctrl을 누른 채 링크를 클릭하면 이번 한 번만 다른 쪽으로 엽니다.", + "sourceTranscript": "대화 메시지", + "sourceToolCard": "도구 결과", + "sourceTerminal": "터미널", + "sourceEditor": "편집기", + "sourceNotification": "알림", + "targetBuiltin": "내장 브라우저", + "targetSystem": "시스템 브라우저", + "devtoolsTitle": "웹 인스펙터", + "devtoolsHint": "브라우저 탭에서 요소 검사를 허용합니다. 이후에 여는 탭부터 적용됩니다.", + "surfaceTitle": "탭 표시 방식", + "surfaceHint": "페이지를 표시하는 방식입니다. 이 시스템에서 내장 탭이 비정상적으로 동작하지 않는 한 「자동」을 유지하세요. 이후에 여는 탭부터 적용됩니다.", + "surfaceAuto": "자동", + "surfaceChild": "작업 공간에 내장", + "surfaceWindow": "별도 창", + "clearTitle": "브라우징 데이터", + "clearHint": "모든 브라우저 탭이 공유하는 쿠키, 캐시, 사이트 저장소입니다. 지우면 각 사이트에서 로그아웃됩니다.", + "clearAction": "지우기…", + "clearConfirmTitle": "브라우징 데이터를 지울까요?", + "clearConfirmDescription": "내장 브라우저의 쿠키, 캐시, 사이트 저장소가 삭제됩니다. 열린 탭은 유지되지만 각 사이트에서 로그아웃됩니다.", + "clearConfirmAction": "지우기", + "cancel": "취소", + "cleared": "브라우징 데이터를 지웠습니다", + "clearFailed": "브라우징 데이터를 지울 수 없습니다: {message}" + }, "LogsSettings": { "loading": "불러오는 중…", "sectionTitle": "실행 로그", @@ -5776,6 +5805,7 @@ "popupDenied": "팝업 차단됨: {host}", "popupDeniedNoGesture": "클릭으로 열린 창이 아닙니다", "popupDeniedBlockedScheme": "허용되지 않는 주소 형식입니다", + "popupOpenAnyway": "그래도 열기", "dismiss": "닫기", "errorDns": "사이트를 찾을 수 없습니다", "errorTls": "안전하지 않은 연결입니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 13726a91a5..3c3802c2d8 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4725,6 +4725,35 @@ "toneAlert": "Alerta", "toneDescend": "Descendente" }, + "BrowserSettings": { + "title": "Navegador integrado", + "description": "Páginas web abertas a partir de conversas, resultados de ferramentas e do terminal podem aparecer como abas ao lado dos seus arquivos, sem sair do aplicativo.", + "defaultTargetTitle": "Onde os links abrem", + "defaultTargetHint": "Definido por origem. Use ⌘/Ctrl+clique em um link para usar a outra opção só desta vez.", + "sourceTranscript": "Mensagens da conversa", + "sourceToolCard": "Resultados de ferramentas", + "sourceTerminal": "Terminal", + "sourceEditor": "Editor", + "sourceNotification": "Notificações", + "targetBuiltin": "Navegador integrado", + "targetSystem": "Navegador do sistema", + "devtoolsTitle": "Inspetor web", + "devtoolsHint": "Permite Inspecionar elemento nas abas do navegador. Vale para abas abertas a partir de agora.", + "surfaceTitle": "Superfície das abas", + "surfaceHint": "Como as páginas são hospedadas. Mantenha Automático, a menos que as abas incorporadas apresentem problemas neste sistema. Vale para abas abertas a partir de agora.", + "surfaceAuto": "Automático", + "surfaceChild": "Incorporada ao espaço de trabalho", + "surfaceWindow": "Janela separada", + "clearTitle": "Dados de navegação", + "clearHint": "Cookies, caches e armazenamento de sites compartilhados por todas as abas do navegador. Limpar encerra sua sessão nos sites.", + "clearAction": "Limpar…", + "clearConfirmTitle": "Limpar dados de navegação?", + "clearConfirmDescription": "Os cookies, caches e o armazenamento de sites do navegador integrado serão removidos. As abas abertas continuam abertas, mas sua sessão nos sites será encerrada.", + "clearConfirmAction": "Limpar", + "cancel": "Cancelar", + "cleared": "Dados de navegação limpos", + "clearFailed": "Não foi possível limpar os dados de navegação: {message}" + }, "LogsSettings": { "loading": "Carregando…", "sectionTitle": "Registros de execução", @@ -5776,6 +5805,7 @@ "popupDenied": "Pop-up bloqueado: {host}", "popupDeniedNoGesture": "não foi aberto por um clique", "popupDeniedBlockedScheme": "tipo de endereço não permitido", + "popupOpenAnyway": "Abrir mesmo assim", "dismiss": "Fechar", "errorDns": "Este site não foi encontrado", "errorTls": "Esta conexão não é segura", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 1b2e7ee5c0..03601360ae 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4725,6 +4725,35 @@ "toneAlert": "警示", "toneDescend": "下行音" }, + "BrowserSettings": { + "title": "内置浏览器", + "description": "从会话、工具结果和终端打开的网页可以作为标签页显示在文件旁边,而不必离开应用。", + "defaultTargetTitle": "链接在哪里打开", + "defaultTargetHint": "按来源分别设置。按住 ⌘/Ctrl 点击链接可临时使用另一种方式。", + "sourceTranscript": "会话消息", + "sourceToolCard": "工具结果", + "sourceTerminal": "终端", + "sourceEditor": "编辑器", + "sourceNotification": "通知", + "targetBuiltin": "内置浏览器", + "targetSystem": "系统浏览器", + "devtoolsTitle": "网页检查器", + "devtoolsHint": "允许在浏览器标签页中使用「检查元素」。对此后打开的标签页生效。", + "surfaceTitle": "标签页承载方式", + "surfaceHint": "网页以何种方式承载。除非嵌入式标签页在本机表现异常,否则保持「自动」。对此后打开的标签页生效。", + "surfaceAuto": "自动", + "surfaceChild": "嵌入工作区", + "surfaceWindow": "独立窗口", + "clearTitle": "浏览数据", + "clearHint": "所有浏览器标签页共用的 Cookie、缓存和站点存储。清除后会退出各网站的登录。", + "clearAction": "清除…", + "clearConfirmTitle": "清除浏览数据?", + "clearConfirmDescription": "将删除内置浏览器的 Cookie、缓存和站点存储。已打开的标签页会保留,但各网站的登录状态会失效。", + "clearConfirmAction": "清除", + "cancel": "取消", + "cleared": "浏览数据已清除", + "clearFailed": "无法清除浏览数据:{message}" + }, "LogsSettings": { "loading": "加载中…", "sectionTitle": "运行日志", @@ -5776,6 +5805,7 @@ "popupDenied": "已拦截弹窗:{host}", "popupDeniedNoGesture": "不是由点击触发", "popupDeniedBlockedScheme": "不允许的地址类型", + "popupOpenAnyway": "仍然打开", "dismiss": "关闭", "errorDns": "找不到该网站", "errorTls": "连接不安全", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d367150aa0..412acbf4f5 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4725,6 +4725,35 @@ "toneAlert": "警示", "toneDescend": "下行音" }, + "BrowserSettings": { + "title": "內建瀏覽器", + "description": "從對話、工具結果和終端機開啟的網頁可以作為分頁顯示在檔案旁邊,而不必離開應用程式。", + "defaultTargetTitle": "連結在哪裡開啟", + "defaultTargetHint": "依來源分別設定。按住 ⌘/Ctrl 點擊連結可暫時改用另一種方式。", + "sourceTranscript": "對話訊息", + "sourceToolCard": "工具結果", + "sourceTerminal": "終端機", + "sourceEditor": "編輯器", + "sourceNotification": "通知", + "targetBuiltin": "內建瀏覽器", + "targetSystem": "系統瀏覽器", + "devtoolsTitle": "網頁檢閱器", + "devtoolsHint": "允許在瀏覽器分頁中使用「檢閱元素」。對之後開啟的分頁生效。", + "surfaceTitle": "分頁承載方式", + "surfaceHint": "網頁以何種方式承載。除非內嵌分頁在本機運作異常,否則請保持「自動」。對之後開啟的分頁生效。", + "surfaceAuto": "自動", + "surfaceChild": "內嵌於工作區", + "surfaceWindow": "獨立視窗", + "clearTitle": "瀏覽資料", + "clearHint": "所有瀏覽器分頁共用的 Cookie、快取和網站儲存資料。清除後會登出各網站。", + "clearAction": "清除…", + "clearConfirmTitle": "要清除瀏覽資料嗎?", + "clearConfirmDescription": "將移除內建瀏覽器的 Cookie、快取和網站儲存資料。已開啟的分頁會保留,但各網站的登入狀態會失效。", + "clearConfirmAction": "清除", + "cancel": "取消", + "cleared": "已清除瀏覽資料", + "clearFailed": "無法清除瀏覽資料:{message}" + }, "LogsSettings": { "loading": "載入中…", "sectionTitle": "執行日誌", @@ -5776,6 +5805,7 @@ "popupDenied": "已攔截彈出視窗:{host}", "popupDeniedNoGesture": "並非由點擊觸發", "popupDeniedBlockedScheme": "不允許的網址類型", + "popupOpenAnyway": "仍要開啟", "dismiss": "關閉", "errorDns": "找不到此網站", "errorTls": "連線不安全", diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index 257fe53e2f..d61b2902c9 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -77,6 +77,8 @@ export interface OpenBrowserTabParams { background?: boolean surface?: SurfaceChoice folderId?: number | null + /** Build the surface with the web inspector available. */ + devtools?: boolean } export function browserOpenTab( @@ -89,6 +91,7 @@ export function browserOpenTab( background: params.background ?? false, surface: params.surface ?? "auto", folderId: params.folderId ?? null, + devtools: params.devtools ?? false, }) } @@ -145,3 +148,8 @@ export function browserGetState(tabId: string): Promise<BrowserTabState> { export function browserListTabs(): Promise<BrowserTabState[]> { return getTransport().call<BrowserTabState[]>("browser_list_tabs", {}) } + +/** Wipe cookies, caches and storage shared by every built-in browser tab. */ +export function browserClearData(): Promise<void> { + return getTransport().call<void>("browser_clear_data", {}) +} From 0e4001d2c530879ea6d2aff7d58b44fa610bbf17 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 21:32:43 +0800 Subject: [PATCH 12/79] fix(browser): keep owned windows visible and raise them on show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface host drove an owned window's visibility from its placeholder rect, and the tab view hides that placeholder — so the window was hidden the moment it was created. Owned windows now follow only the tab-on-screen signals and never receive bounds; showing one also focuses it. --- src-tauri/src/browser/surface.rs | 6 +++- .../browser/browser-surface-host.test.tsx | 33 +++++++++++++++++++ .../browser/browser-surface-host.tsx | 14 +++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index 8c63d17c1c..caa210db50 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -109,7 +109,11 @@ impl BrowserSurface { } pub fn show(&self) -> Result<(), SurfaceError> { - per_surface!(self, child: |c| Ok(c.set_visible(true)?), window: |w| Ok(w.show()?)) + per_surface!(self, + child: |c| Ok(c.set_visible(true)?), + // Un-hiding alone can leave the window behind its owner; showing + // it is a request to see it. + window: |w| { w.show()?; w.set_focus()?; Ok(()) }) } pub fn set_focus(&self) -> Result<(), SurfaceError> { diff --git a/src/components/browser/browser-surface-host.test.tsx b/src/components/browser/browser-surface-host.test.tsx index d411b4d3d7..52c84f402e 100644 --- a/src/components/browser/browser-surface-host.test.tsx +++ b/src/components/browser/browser-surface-host.test.tsx @@ -132,6 +132,39 @@ describe("BrowserSurfaceHost", () => { ) }) + it("drives an owned window from tab visibility, not from its invisible placeholder", async () => { + // The tab view hides the placeholder (`invisible`) when the page lives in + // its own window; the window must still be shown, and never sized. + Object.defineProperty(HTMLElement.prototype, "checkVisibility", { + configurable: true, + value: () => false, + }) + try { + api.browserOpenTab.mockImplementation(() => + Promise.resolve({ ...state("host-window"), surface: "window" }) + ) + const { unmount } = render( + <BrowserSurfaceHost tab={tab("host-window")} /> + ) + await flush() + expect(api.browserSetBounds).not.toHaveBeenCalled() + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host-window", + true, + false + ) + unmount() + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host-window", + false, + false + ) + } finally { + delete (HTMLElement.prototype as { checkVisibility?: unknown }) + .checkVisibility + } + }) + it("does not create a second surface for a tab that already has one", async () => { api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host2"))) const first = render(<BrowserSurfaceHost tab={tab("host2")} />) diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index b570828740..7d08e4d6c5 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -97,6 +97,18 @@ export function BrowserSurfaceHost({ const sync = useCallback(() => { const el = ref.current if (!el || !backendId) return + // An owned window is not fitted to this element — the placeholder is + // invisible by design — so only the "is this tab on screen" signals + // apply, and there are no bounds to push. + if (getBrowserTabState(tab.id)?.surface === "window") { + if (lastVisibleRef.current !== shouldShow) { + lastVisibleRef.current = shouldShow + void browserSetVisible(backendId, shouldShow, !shouldShow).catch( + () => {} + ) + } + return + } const bounds = measure(el) const visible = shouldShow && bounds.width > 0 && bounds.height > 0 && elementVisible(el) @@ -108,7 +120,7 @@ export function BrowserSurfaceHost({ lastVisibleRef.current = visible void browserSetVisible(backendId, visible, !visible).catch(() => {}) } - }, [backendId, shouldShow]) + }, [backendId, shouldShow, tab.id]) // Create the surface once per tab record; adopted popups and re-mounts // already have one (the store knows about it). From 56612bdc73ade70831ef6d2762912c6d02d87dc3 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 22:27:44 +0800 Subject: [PATCH 13/79] fix(browser): never read URLs through wry and surface failed navigations wry 0.55.1 unwraps `WKWebView.URL`, which is nil until a document commits: calling tauri's `WebviewWindow::url()` on a browser surface that never navigated (or whose first navigation failed) panics the main thread and takes the app with it. Child surfaces now read their URL through the macOS shim and owned windows report theirs from the tab state; the dev puppet skips `browser-*` windows. wry also has no navigation-failure callback and reports "load started" at `didCommitNavigation`, so a host that never answers left the tab spinning forever. Every navigation now arms a `load_seq`-guarded watcher that polls `WKWebView.isLoading` and, once the engine gives up without a document, raises `error: failed` with the requested URL. Timing follows the engine (a refused connection ~10s, a black-holed one ~60s); nothing is capped on our side. The wording is the status layer's, so the error page reads in the user's language instead of an English backend string. --- src-tauri/src/browser/hooks.rs | 89 +++++++++++++++++++ src-tauri/src/browser/registry.rs | 4 + src-tauri/src/browser/shim/macos.rs | 22 ++++- src-tauri/src/browser/smoke.rs | 7 +- src-tauri/src/browser/surface.rs | 14 ++- src-tauri/src/browser/surface_child.rs | 32 ++++++- src-tauri/src/commands/browser.rs | 4 +- .../browser/browser-status-layer.test.tsx | 43 ++++++++- .../browser/browser-status-layer.tsx | 14 +-- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 19 files changed, 226 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index c55ff7685a..a77d3ac888 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -2,10 +2,13 @@ //! registry and emit state; nothing here calls back into the surface, so the //! webview thread never waits on itself. +use std::time::Duration; + use tauri::{AppHandle, Manager, Url}; use super::events; use super::registry::BrowserRegistry; +use super::types::{BrowserErrorInfo, BrowserErrorKind}; pub fn origin_of(url: &Url) -> Option<String> { let origin = url.origin(); @@ -53,6 +56,92 @@ pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { if let Some(state) = state { events::emit_state(app, &state); } + if started { + begin_load(app, tab_id); + } +} + +/// Arm the failed-load watcher for a navigation that is starting now. Called +/// where a load is kicked off (open, address bar, adopted popup) as well as +/// from `page_load`: wry's "started" is WebKit's `didCommitNavigation`, so a +/// navigation that never commits (DNS failure) never reports starting at all. +pub fn begin_load(app: &AppHandle, tab_id: &str) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + if let Some(seq) = registry.update(tab_id, |tab| { + tab.load_seq += 1; + tab.load_seq + }) { + watch_load(app.clone(), tab_id.to_string(), seq); + } +} + +const LOAD_POLL: Duration = Duration::from_millis(500); +const LOAD_FINISH_GRACE: Duration = Duration::from_millis(300); + +/// wry reports navigation start and finish but never failure, so a DNS, +/// connection or TLS error would leave `loading: true` forever. Poll the +/// engine's own flag until it clears; if our state is still loading a moment +/// later, the load ended without a document — surface it as an error, or, +/// when an older document is still showing, just stop the spinner. A newer +/// navigation (higher `load_seq`) retires the watcher. +fn watch_load(app: AppHandle, tab_id: String, seq: u64) { + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(LOAD_POLL).await; + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let Some((surface, loading, current)) = registry.update(&tab_id, |tab| { + (tab.surface.clone(), tab.state.loading, tab.load_seq) + }) else { + return; + }; + if current != seq || !loading { + return; + } + match surface.is_loading() { + Ok(true) => continue, + Ok(false) => {} + // No load state on this surface (owned windows for now). + Err(_) => return, + } + // `didFinish` may still be on its way. + tokio::time::sleep(LOAD_FINISH_GRACE).await; + let Some((loading, current)) = + registry.update(&tab_id, |tab| (tab.state.loading, tab.load_seq)) + else { + return; + }; + if current != seq || !loading { + return; + } + let has_document = surface.url().is_ok(); + let next = registry.update_state(&tab_id, |state| { + state.loading = false; + if !has_document { + // Nothing committed, so `url` is still empty: the error + // page needs the address the user asked for. The wording + // is the status layer's, in the user's language. + let url = if state.url.is_empty() { + state.requested_url.clone() + } else { + state.url.clone() + }; + state.error = Some(BrowserErrorInfo { + kind: BrowserErrorKind::Failed, + message: String::new(), + url: Some(url), + }); + } + }); + if let Some(next) = next { + events::emit_state(&app, &next); + } + return; + } + }); } pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index e3635a0e7a..8716978f38 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -36,6 +36,9 @@ pub struct BrowserTab { /// Whether the surface was built with the inspector enabled (a user /// preference read at open time). Popups inherit their opener's value. pub devtools: bool, + /// Bumped on every navigation start; a load watcher captures it and + /// stands down when a newer navigation supersedes its own. + pub load_seq: u64, pub gestures: VecDeque<GestureRecord>, } @@ -53,6 +56,7 @@ impl BrowserTab { last_bounds: bounds, visible, devtools, + load_seq: 0, gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), } } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index f93e7b75a2..0b23b78a97 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -276,6 +276,22 @@ pub fn clear_default_data_store(done: impl Fn() + 'static) -> Result<(), String> Ok(()) } +/// `WKWebView.URL`, or `None` before any navigation has committed (and after +/// a first navigation failed). wry's own `url()` unwraps this and panics. +pub fn current_url(webview: &wry::WebView) -> Option<String> { + let wk = WebViewExtMacOS::webview(webview); + // SAFETY: main thread; WebKit hands back an owned NSURL / NSString. + unsafe { wk.URL().and_then(|u| u.absoluteString()).map(|s| s.to_string()) } +} + +/// `WKWebView.isLoading`. wry has no navigation-failure callback, so this is +/// the only way to notice that a load ended without finishing. +pub fn is_loading(webview: &wry::WebView) -> bool { + let wk = WebViewExtMacOS::webview(webview); + // SAFETY: main thread. + unsafe { wk.isLoading() } +} + pub fn webview_pointer(webview: &wry::WebView) -> usize { Retained::as_ptr(&webview.webview()) as usize } @@ -284,7 +300,7 @@ pub fn webview_pointer(webview: &wry::WebView) -> usize { pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { let wk = webview.webview(); // SAFETY: main thread, live view. - let (hidden, hidden_or_ancestor, has_window, has_superview, frame) = unsafe { + let (hidden, hidden_or_ancestor, has_window, has_superview, frame, loading, has_url) = unsafe { let frame = wk.frame(); ( wk.isHidden(), @@ -292,10 +308,14 @@ pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { wk.window().is_some(), wk.superview().is_some(), [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height], + wk.isLoading(), + wk.URL().is_some(), ) }; serde_json::json!({ "hidden": hidden, + "isLoading": loading, + "hasUrl": has_url, "hiddenOrAncestor": hidden_or_ancestor, "hasWindow": has_window, "hasSuperview": has_superview, diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 1d25d7177f..1f90b95b60 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -113,7 +113,12 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { "position": w.outer_position().ok().map(|p| [p.x, p.y]), "size": w.inner_size().ok().map(|s| [s.width, s.height]), "scale": w.scale_factor().ok(), - "url": w.url().ok().map(|u| u.to_string()), + // Never `url()` a browser window: see `BrowserSurface::url`. + "url": if w.label().starts_with(crate::browser::TAB_LABEL_PREFIX) { + None + } else { + w.url().ok().map(|u| u.to_string()) + }, }) }) .collect(); diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index caa210db50..0821b9e676 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -84,10 +84,14 @@ impl BrowserSurface { per_surface!(self, child: |c| Ok(c.reload()?), window: |w| Ok(w.reload()?)) } + /// Dev puppet only. Owned windows are deliberately not asked: wry 0.55's + /// `url()` unwraps `WKWebView.URL`, which is nil until a navigation has + /// committed (or after the first one failed), and that unwrap panics the + /// main thread. The registry state carries the URL either way. pub fn url(&self) -> Result<Url, SurfaceError> { per_surface!(self, child: |c| Url::parse(&c.url()?).map_err(|e| SurfaceError(e.to_string())), - window: |w| Ok(w.url()?)) + window: |_w| Err(SurfaceError("owned windows report their URL through the tab state".into()))) } pub fn eval(&self, js: &str) -> Result<(), SurfaceError> { @@ -169,6 +173,14 @@ impl BrowserSurface { window: |_w| Err(SurfaceError("history navigation for owned windows lands with the platform shims".into()))) } + /// Engine-side "still loading", used to notice failed navigations (wry + /// reports no failure event). Owned windows: not yet. + pub fn is_loading(&self) -> Result<bool, SurfaceError> { + per_surface!(self, + child: |c| Ok(c.is_loading()?), + window: |_w| Err(SurfaceError("load state for owned windows lands with the platform shims".into()))) + } + pub fn can_go_back(&self) -> Result<bool, SurfaceError> { per_surface!(self, child: |c| Ok(c.can_go_back()?), window: |_w| Ok(false)) } diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index df087b56c6..3fe112ce54 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -162,9 +162,34 @@ impl ChildHandle { self.op(|wv| wv.reload()) } + /// The committed URL. `Err` while nothing has committed yet — never + /// wry's `url()`, whose `unwrap` of a nil `WKWebView.URL` panics the + /// main thread. pub fn url(&self) -> Result<String, ChildError> { - self.with(|wv| wv.url().map_err(|e| e.to_string()))? - .map_err(ChildError::Op) + #[cfg(target_os = "macos")] + { + self.with(shim::current_url)? + .ok_or_else(|| ChildError::Op("no committed URL yet".into())) + } + #[cfg(not(target_os = "macos"))] + { + self.with(|wv| wv.url().map_err(|e| e.to_string()))? + .map_err(ChildError::Op) + } + } + + /// Whether the engine still has a navigation in flight. + pub fn is_loading(&self) -> Result<bool, ChildError> { + #[cfg(target_os = "macos")] + { + self.with(shim::is_loading) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "load state is not implemented on this platform yet".into(), + )) + } } pub fn evaluate_script(&self, js: &str) -> Result<(), ChildError> { @@ -587,6 +612,9 @@ fn new_window_handler( SURFACES.with(|s| s.borrow_mut().remove(&tab_id)); return deny(&app, &opener_tab_id, &url, &features, "registry"); } + // The engine navigates this webview itself; arm the failed-load + // watcher since a never-committing load reports nothing. + hooks::begin_load(&app, &tab_id); events::emit_state(&app, &state); events::emit_popup( &app, diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 5e95e73810..ab9b4ad509 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -12,7 +12,7 @@ use crate::browser::surface::BrowserSurface; use crate::browser::types::{ Bounds, BrowserCapabilities, BrowserTabState, ChannelKind, SurfaceChoice, SurfaceKind, }; -use crate::browser::{events, policy, tab_label}; +use crate::browser::{events, hooks, policy, tab_label}; #[cfg(all( feature = "browser-child", @@ -228,6 +228,7 @@ pub fn open_tab_core( let _ = surface.close(); return Err(window_err("Failed to navigate browser tab", err)); } + hooks::begin_load(app, ¶ms.tab_id); events::emit_state(app, &state); Ok(state) } @@ -381,6 +382,7 @@ pub fn navigate_core( surface .navigate(url) .map_err(|e| window_err("Failed to navigate browser tab", e))?; + hooks::begin_load(app, tab_id); events::emit_state(app, &state); Ok(state) } diff --git a/src/components/browser/browser-status-layer.test.tsx b/src/components/browser/browser-status-layer.test.tsx index 0319462a22..834ee90dab 100644 --- a/src/components/browser/browser-status-layer.test.tsx +++ b/src/components/browser/browser-status-layer.test.tsx @@ -12,7 +12,7 @@ vi.mock("@/contexts/workspace-context", () => ({ vi.mock("@/lib/browser/browser-api", () => ({ browserReload: vi.fn() })) vi.mock("@/lib/platform", () => ({ openUrl: vi.fn() })) -import { BrowserNoticeBar } from "./browser-status-layer" +import { BrowserErrorPage, BrowserNoticeBar } from "./browser-status-layer" import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" import enMessages from "@/i18n/messages/en.json" import { @@ -94,3 +94,44 @@ describe("BrowserNoticeBar", () => { expect(screen.queryByText(/Pop-up blocked/)).not.toBeInTheDocument() }) }) + +function renderError(error: { + kind: string + message: string + url: string | null +}) { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserErrorPage + tab={tab} + error={error as never} + url="https://example.com/" + /> + </NextIntlClientProvider> + ) +} + +describe("BrowserErrorPage", () => { + it("explains a navigation that never produced a page", () => { + renderError({ + kind: "failed", + message: "", + url: "https://news.example.com/", + }) + expect(screen.getByText("This page can't be loaded")).toBeInTheDocument() + expect(screen.getByText("https://news.example.com/")).toBeInTheDocument() + expect(screen.getByText(/a proxy may be required/)).toBeInTheDocument() + }) + + it("prefers the platform's own wording and the tab URL as a fallback", () => { + renderError({ kind: "tls", message: "certificate expired", url: null }) + expect( + screen.getByText("This connection is not secure") + ).toBeInTheDocument() + expect(screen.getByText("https://example.com/")).toBeInTheDocument() + expect(screen.getByText("certificate expired")).toBeInTheDocument() + expect( + screen.queryByText(/a proxy may be required/) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index f3b79f3972..25ab5b5b72 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -110,6 +110,10 @@ export function BrowserErrorPage({ }) { const t = useTranslations("Browser.status") const backendId = browserTabBackendId(tab.id) + // Platform errors carry their own (untranslated) text; the one we raise + // ourselves for a navigation that never produced a page does not. + const detail = + error.message || (error.kind === "failed" ? t("errorFailedHint") : "") return ( <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> <ShieldAlert className="h-8 w-8 text-muted-foreground/60" /> @@ -117,12 +121,10 @@ export function BrowserErrorPage({ {errorLabel(t, error)} </p> <p className="max-w-md break-all text-xs text-muted-foreground"> - {error.url ?? url} + {error.url || url} </p> - {error.message ? ( - <p className="max-w-md text-xs text-muted-foreground/80"> - {error.message} - </p> + {detail ? ( + <p className="max-w-md text-xs text-muted-foreground/80">{detail}</p> ) : null} <div className="mt-1 flex items-center gap-2"> <button @@ -136,7 +138,7 @@ export function BrowserErrorPage({ <button type="button" className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" - onClick={() => void openUrl(error.url ?? url)} + onClick={() => void openUrl(error.url || url)} > <ExternalLink className="h-3.5 w-3.5" /> {t("openInSystem")} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3b327e1924..536c50e317 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5812,6 +5812,7 @@ "errorBlocked": "هذا العنوان محظور في المتصفح المدمج", "errorPopupDenied": "تم حظر النافذة المنبثقة", "errorFailed": "تعذّر تحميل هذه الصفحة", + "errorFailedHint": "تعذّر الاتصال بالموقع. تحقّق من الشبكة والعنوان، وقد تحتاج إلى وكيل (proxy).", "retry": "إعادة المحاولة", "openInSystem": "فتح في متصفح النظام", "ownedWindow": "تُعرض هذه الصفحة في نافذة مستقلة.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 3b72515567..07ea98b177 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5812,6 +5812,7 @@ "errorBlocked": "Diese Adresse ist im integrierten Browser gesperrt", "errorPopupDenied": "Pop-up blockiert", "errorFailed": "Diese Seite kann nicht geladen werden", + "errorFailedHint": "Die Verbindung zur Website kam nicht zustande. Prüfe dein Netzwerk und die Adresse; eventuell ist ein Proxy nötig.", "retry": "Erneut versuchen", "openInSystem": "Im Systembrowser öffnen", "ownedWindow": "Diese Seite wird in einem eigenen Fenster angezeigt.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ced38ebc63..99a677bf74 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5812,6 +5812,7 @@ "errorBlocked": "This address is blocked in the built-in browser", "errorPopupDenied": "Pop-up blocked", "errorFailed": "This page can't be loaded", + "errorFailedHint": "The connection didn't reach the site. Check your network and the address; a proxy may be required.", "retry": "Retry", "openInSystem": "Open in system browser", "ownedWindow": "This page is shown in its own window.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index fd5b4ee293..927aefd93b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5812,6 +5812,7 @@ "errorBlocked": "Esta dirección está bloqueada en el navegador integrado", "errorPopupDenied": "Ventana emergente bloqueada", "errorFailed": "No se puede cargar esta página", + "errorFailedHint": "No se pudo conectar con el sitio. Comprueba tu red y la dirección; puede que necesites un proxy.", "retry": "Reintentar", "openInSystem": "Abrir en el navegador del sistema", "ownedWindow": "Esta página se muestra en su propia ventana.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c91ecbd817..087a2fe7d6 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5812,6 +5812,7 @@ "errorBlocked": "Cette adresse est bloquée dans le navigateur intégré", "errorPopupDenied": "Fenêtre contextuelle bloquée", "errorFailed": "Impossible de charger cette page", + "errorFailedHint": "La connexion au site n'a pas abouti. Vérifiez votre réseau et l'adresse ; un proxy est peut-être nécessaire.", "retry": "Réessayer", "openInSystem": "Ouvrir dans le navigateur système", "ownedWindow": "Cette page s'affiche dans sa propre fenêtre.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fbc120c9cc..cfc53bd8de 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5812,6 +5812,7 @@ "errorBlocked": "内蔵ブラウザではこのアドレスを開けません", "errorPopupDenied": "ポップアップがブロックされました", "errorFailed": "ページを読み込めません", + "errorFailedHint": "サイトに接続できませんでした。ネットワークとアドレスを確認してください。プロキシが必要な場合もあります。", "retry": "再試行", "openInSystem": "システムのブラウザで開く", "ownedWindow": "このページは別ウィンドウに表示されています。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d553b7a263..83e4ce7b88 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5812,6 +5812,7 @@ "errorBlocked": "내장 브라우저에서 열 수 없는 주소입니다", "errorPopupDenied": "팝업이 차단되었습니다", "errorFailed": "페이지를 불러올 수 없습니다", + "errorFailedHint": "사이트에 연결하지 못했습니다. 네트워크와 주소를 확인하세요. 프록시가 필요할 수도 있습니다.", "retry": "다시 시도", "openInSystem": "시스템 브라우저에서 열기", "ownedWindow": "이 페이지는 별도의 창에 표시됩니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3c3802c2d8..c181bd1b0d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5812,6 +5812,7 @@ "errorBlocked": "Este endereço está bloqueado no navegador integrado", "errorPopupDenied": "Pop-up bloqueado", "errorFailed": "Não foi possível carregar esta página", + "errorFailedHint": "A ligação ao site não foi estabelecida. Verifique a sua rede e o endereço; pode ser necessário um proxy.", "retry": "Tentar novamente", "openInSystem": "Abrir no navegador do sistema", "ownedWindow": "Esta página é exibida em uma janela própria.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 03601360ae..a0fe921335 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5812,6 +5812,7 @@ "errorBlocked": "内置浏览器不允许打开该地址", "errorPopupDenied": "弹窗已拦截", "errorFailed": "页面无法加载", + "errorFailedHint": "没有连接到该网站。请检查网络和地址,必要时可能需要代理。", "retry": "重试", "openInSystem": "在系统浏览器中打开", "ownedWindow": "该页面显示在独立窗口中。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 412acbf4f5..5fac964c1c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5812,6 +5812,7 @@ "errorBlocked": "內建瀏覽器不允許開啟此網址", "errorPopupDenied": "彈出視窗已攔截", "errorFailed": "無法載入頁面", + "errorFailedHint": "沒有連線到該網站。請檢查網路與網址,必要時可能需要代理。", "retry": "重試", "openInSystem": "在系統瀏覽器中開啟", "ownedWindow": "此頁面顯示在獨立視窗中。", From b21da17c421f4ad8e04a4ebd5e1f06fa155fefc4 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 22:39:04 +0800 Subject: [PATCH 14/79] fix(browser): make retry restart a navigation that never committed `WKWebView.reload()` has nothing to reload when the first navigation failed before committing a document, so the error page's retry button did nothing at all. `reload_core` now re-runs the requested navigation when the surface has no document, which also re-arms the load watcher. --- src-tauri/src/browser/smoke.rs | 3 ++- src-tauri/src/commands/browser.rs | 22 +++++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 1f90b95b60..a453fb0b1b 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -273,7 +273,8 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { Ok(json!(state)) } "browser_reload" => { - browser_commands::reload_core(®istry, &str_arg(cmd, "tab_id")?).map_err(err_string)?; + browser_commands::reload_core(app, ®istry, &str_arg(cmd, "tab_id")?) + .map_err(err_string)?; Ok(Value::Null) } "browser_close" => { diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index ab9b4ad509..6d2c4d3142 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -387,8 +387,23 @@ pub fn navigate_core( Ok(state) } -pub fn reload_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { - surface_of(registry, tab_id)? +pub fn reload_core( + app: &AppHandle, + registry: &BrowserRegistry, + tab_id: &str, +) -> Result<(), AppCommandError> { + let surface = surface_of(registry, tab_id)?; + // A navigation that failed before committing left no document behind, and + // reloading nothing does nothing — the error page's retry button has to + // start the requested navigation over. + if surface.url().is_err() { + let requested = registry.state(tab_id).map(|s| s.requested_url); + if let Some(url) = requested.filter(|u| !u.is_empty()) { + navigate_core(app, registry, tab_id, &url)?; + return Ok(()); + } + } + surface .reload() .map_err(|e| window_err("Failed to reload browser tab", e)) } @@ -513,10 +528,11 @@ pub async fn browser_navigate( #[tauri::command] pub async fn browser_reload( + app: AppHandle, registry: State<'_, BrowserRegistry>, tab_id: String, ) -> Result<(), AppCommandError> { - reload_core(®istry, &tab_id) + reload_core(&app, ®istry, &tab_id) } #[tauri::command] From 3cccf7f87a365df18f3806d4b230cdc4088ab8f4 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 23:58:48 +0800 Subject: [PATCH 15/79] feat(browser): give tabs their own profile and follow the app proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser tabs used to live in WebKit's default data store together with the workspace webview, so "clear browsing data" wiped the app's own localStorage along with the pages' cookies. Tabs now get a profile of their own: a persistent `WKWebsiteDataStore` by identifier on macOS 14+, a dedicated WebView2 user-data folder on Windows and a data directory on Linux. Regular tabs are built from a configuration that carries that store; popups keep inheriting their opener's. Clearing works on the profile only (older macOS, where there is no separate store, removes records per origin and spares the app's hosts). That container is also where a proxy lives, so the built-in browser now uses the app's "system proxy" setting — the same process environment the app's own requests and agent processes see. macOS applies it live to the profile store through `proxyConfigurations` (Network.framework resolved at run time; the symbols exist only on macOS 14+), with loopback excluded so local dev servers keep working; Windows freezes it in the environment arguments of the first browser webview (a restart applies a change); Linux passes it per window. `browser_capabilities` reports `isolatedStorage` and the proxy status, and the settings section shows a read-only "Network proxy" row. --- src-tauri/Cargo.toml | 4 +- src-tauri/src/browser/mod.rs | 2 + src-tauri/src/browser/profile.rs | 362 ++++++++++++++++++ src-tauri/src/browser/shim/macos.rs | 308 ++++++++++++++- src-tauri/src/browser/smoke.rs | 50 ++- src-tauri/src/browser/surface_child.rs | 95 ++++- src-tauri/src/browser/surface_window.rs | 29 ++ src-tauri/src/browser/types.rs | 3 + src-tauri/src/commands/browser.rs | 66 ++-- src-tauri/src/commands/system_settings.rs | 2 + src-tauri/src/network/proxy.rs | 24 ++ src-tauri/src/paths.rs | 18 + src-tauri/src/web/handlers/system_settings.rs | 4 + .../browser/browser-events-bridge.test.tsx | 4 + .../settings/browser-settings.test.tsx | 69 ++++ src/components/settings/browser-settings.tsx | 62 ++- src/hooks/use-open-url-target.test.tsx | 2 + src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- src/lib/browser/browser-api.ts | 11 + src/lib/browser/types.test.ts | 6 + src/lib/browser/types.ts | 19 + 30 files changed, 1170 insertions(+), 70 deletions(-) create mode 100644 src-tauri/src/browser/profile.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a7c66056f..c9fc14feca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -181,8 +181,8 @@ mac-notification-sys = "0.6" # with the handles wry hands out. objc2 = "0.6" block2 = "0.6" -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet"] } -objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation", "WKWebsiteDataStore"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet", "NSProcessInfo", "NSUUID"] } +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation", "WKWebsiteDataStore", "WKWebsiteDataRecord"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index 7ddbd1c6d2..3c34a862b7 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -13,6 +13,7 @@ //! Module map: //! - `types` — wire types shared with `src/lib/browser/types.ts` //! - `policy` — pure decisions (scheme allow-list, …) +//! - `profile` — the tabs' own data store / directory and their proxy //! - `registry` — tab id → surface + last known state //! - `surface` — the enum over the concrete surfaces and their common ops //! - `surface_child` / `surface_window` — the concrete builders @@ -27,6 +28,7 @@ pub mod channel; pub mod events; pub mod hooks; pub mod policy; +pub mod profile; pub mod registry; pub mod surface; #[cfg(all( diff --git a/src-tauri/src/browser/profile.rs b/src-tauri/src/browser/profile.rs new file mode 100644 index 0000000000..b3bc037dd3 --- /dev/null +++ b/src-tauri/src/browser/profile.rs @@ -0,0 +1,362 @@ +//! Browser profile: where tabs keep cookies, caches and storage, and which +//! proxy their traffic goes through. P1 ships one profile, `default`. +//! +//! Why tabs need a container of their own: the workspace webview keeps the +//! app's own localStorage and IndexedDB in WebKit's default data store (macOS) +//! / the app's WebView2 user-data folder (Windows), so "clear browsing data" +//! must never run against the store the app lives in, and a page opened in a +//! tab should not share a cookie jar with the app or with remote-workspace +//! windows. A proxy is a property of that same container +//! (`WKWebsiteDataStore.proxyConfigurations`, WebView2 environment arguments, +//! the WebKitGTK network session), which is the second reason. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +pub const DEFAULT_PROFILE_ID: &str = "default"; + +/// `WKWebsiteDataStore` identifier of the default profile (macOS 14+): +/// uuid5(NAMESPACE_URL, "https://codeg.app/browser-profile/default"). Fixed so +/// the same store is found again after a restart or an update. +pub const DEFAULT_DATA_STORE_IDENTIFIER: [u8; 16] = [ + 0xb5, 0xb1, 0xc6, 0x31, 0xe0, 0x8c, 0x58, 0xf2, 0xba, 0x41, 0x9d, 0x11, 0x62, 0x85, 0x6f, 0x26, +]; + +/// Directory holding the profile's data on Windows (WebView2 user-data +/// folder) and Linux (WebKitGTK data directory). Unused on macOS, where the +/// data store identifier plays this role. +pub fn directory(profile_id: &str) -> PathBuf { + crate::paths::codeg_browser_profiles_root().join(profile_id) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProxyScheme { + Http, + Socks5, +} + +/// A proxy in the shape every webview engine's hook can take. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserProxy { + pub scheme: ProxyScheme, + pub host: String, + pub port: u16, +} + +impl BrowserProxy { + /// `scheme://host:port` — what WebView2's `--proxy-server` and tauri's + /// `proxy_url` accept. + pub fn to_url_string(&self) -> String { + let scheme = match self.scheme { + ProxyScheme::Http => "http", + ProxyScheme::Socks5 => "socks5", + }; + let host = if self.host.contains(':') && !self.host.starts_with('[') { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + format!("{scheme}://{host}:{}", self.port) + } +} + +/// Parse the app's (already normalized) proxy URL into what a webview can +/// use. `http`, `socks5` and `socks5h` are accepted — the engines resolve +/// names proxy-side for SOCKS regardless of the `h`. `https` (TLS to the proxy +/// itself) is refused: none of the three engines' proxy hooks express it, so +/// pretending it is plain HTTP CONNECT would send cleartext to a TLS port. +pub fn parse_proxy(raw: &str) -> Result<BrowserProxy, String> { + let url = tauri::Url::parse(raw.trim()).map_err(|e| format!("invalid proxy URL {raw:?}: {e}"))?; + let scheme = match url.scheme() { + "http" => ProxyScheme::Http, + "socks5" | "socks5h" => ProxyScheme::Socks5, + other => { + return Err(format!( + "the built-in browser cannot use a {other}:// proxy (http and socks5 only)" + )) + } + }; + let host = url + .host_str() + .filter(|h| !h.is_empty()) + .ok_or_else(|| format!("proxy URL {raw:?} has no host"))? + .trim_matches(|c| c == '[' || c == ']') + .to_string(); + let port = url.port().unwrap_or(match scheme { + ProxyScheme::Http => 80, + ProxyScheme::Socks5 => 1080, + }); + Ok(BrowserProxy { scheme, host, port }) +} + +/// The proxy browser tabs should use right now. +/// +/// The source is the process environment: the app's "system proxy" setting +/// writes `HTTP(S)_PROXY` / `ALL_PROXY` when enabled and clears them when +/// disabled, and values a shell or service manager exported are honoured the +/// same way the app honours them for its own requests and for agent +/// processes. `Err` means a proxy is configured but the browser cannot use it. +pub fn current_proxy() -> Result<Option<BrowserProxy>, String> { + match crate::network::proxy::effective_proxy_url() { + None => Ok(None), + Some(url) => parse_proxy(&url).map(Some), + } +} + +/// How the platform takes a proxy change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProxyApplies { + /// Open and new tabs use the new proxy for their next connections. + Live, + /// Tabs opened after the change use it; open tabs keep the old one. + NextTab, + /// The engine fixes the proxy for the process; restart to change it. + Restart, + /// This platform (version) cannot proxy browser tabs. + Unsupported, +} + +/// Wire shape of the proxy part of `browser_capabilities`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserProxyStatus { + /// Proxy browser tabs use, as `scheme://host:port`; `None` = direct. + pub url: Option<String>, + pub applies: ProxyApplies, + /// Why `url` is `None` although a proxy is configured (unsupported + /// scheme or platform), or why the shown proxy is not the configured one + /// (Windows until a restart). + pub reason: Option<String>, +} + +/// Windows: WebView2 reads the proxy from the environment's browser +/// arguments, which are fixed for a user-data folder for the life of the +/// process — a later environment with different arguments fails to create. +/// The first browser webview therefore freezes the proxy for this run. +#[cfg(target_os = "windows")] +static FROZEN_PROXY: std::sync::OnceLock<Option<BrowserProxy>> = std::sync::OnceLock::new(); + +/// Windows: the proxy every browser webview of this process is built with. +#[cfg(target_os = "windows")] +pub fn frozen_proxy() -> Option<BrowserProxy> { + FROZEN_PROXY + .get_or_init(|| current_proxy().ok().flatten()) + .clone() +} + +/// Browser arguments for WebView2. wry's own default flags come first, then +/// silent Integrated Windows Authentication is switched off for every host +/// (an arbitrary page must not be able to make the engine present the user's +/// Windows credentials), then the proxy. wry only injects `--proxy-server` +/// itself when no arguments are given at all, so once we hand it a string we +/// own the whole thing — hence one function for the entire string. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub fn windows_browser_args(proxy: Option<&BrowserProxy>) -> String { + let mut args = String::from( + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --auth-server-allowlist=", + ); + if let Some(proxy) = proxy { + args.push_str(" --proxy-server="); + args.push_str(&proxy.to_url_string()); + } + args +} + +pub fn proxy_status() -> BrowserProxyStatus { + platform_proxy_status() +} + +#[cfg(target_os = "macos")] +fn platform_proxy_status() -> BrowserProxyStatus { + if !crate::browser::shim::macos::supports_isolated_profile() { + return BrowserProxyStatus { + url: None, + applies: ProxyApplies::Unsupported, + reason: Some("proxying browser tabs needs macOS 14 or later".to_string()), + }; + } + status_for(ProxyApplies::Live, current_proxy()) +} + +#[cfg(target_os = "windows")] +fn platform_proxy_status() -> BrowserProxyStatus { + let frozen = frozen_proxy(); + let mut status = status_for(ProxyApplies::Restart, current_proxy()); + if FROZEN_PROXY.get().is_some() && status.url != frozen.as_ref().map(BrowserProxy::to_url_string) { + status.reason = Some("restart codeg for browser tabs to use the new proxy".to_string()); + status.url = frozen.map(|p| p.to_url_string()); + } + status +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn platform_proxy_status() -> BrowserProxyStatus { + status_for(ProxyApplies::NextTab, current_proxy()) +} + +fn status_for(applies: ProxyApplies, proxy: Result<Option<BrowserProxy>, String>) -> BrowserProxyStatus { + match proxy { + Ok(proxy) => BrowserProxyStatus { + url: proxy.map(|p| p.to_url_string()), + applies, + reason: None, + }, + Err(reason) => BrowserProxyStatus { + url: None, + applies, + reason: Some(reason), + }, + } +} + +/// Whether browsing data lives apart from the app's own web storage. +pub fn isolated_storage() -> bool { + #[cfg(target_os = "macos")] + { + crate::browser::shim::macos::supports_isolated_profile() + } + #[cfg(not(target_os = "macos"))] + { + true + } +} + +/// Get the profile ready for a tab: its directory exists (Windows / Linux) +/// and, on macOS, its data store exists and points at the current proxy. +/// Idempotent and cheap after the first call. +pub fn prepare(app: &AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let proxy = current_proxy(); + run_on_main(app, move || { + crate::browser::shim::macos::ensure_profile(proxy_or_none(proxy)) + })? + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + let dir = directory(DEFAULT_PROFILE_ID); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("cannot create browser profile directory {}: {e}", dir.display())) + } +} + +/// The app's proxy setting changed. macOS re-points the profile's store, which +/// open tabs pick up for their next connections; the other platforms take the +/// change at the next tab (Linux) or restart (Windows) — see `ProxyApplies`. +pub fn proxy_settings_changed(app: &AppHandle) { + #[cfg(target_os = "macos")] + { + let proxy = current_proxy(); + if let Err(err) = run_on_main(app, move || { + crate::browser::shim::macos::ensure_profile(proxy_or_none(proxy)) + }) + .and_then(|r| r) + { + tracing::warn!("[browser] could not apply the proxy to the browser profile: {err}"); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + } +} + +/// An unusable proxy (unsupported scheme) means direct connections, not a +/// failed tab: the settings section explains why. +#[cfg(target_os = "macos")] +fn proxy_or_none(proxy: Result<Option<BrowserProxy>, String>) -> Option<BrowserProxy> { + match proxy { + Ok(proxy) => proxy, + Err(reason) => { + tracing::warn!("[browser] ignoring the configured proxy: {reason}"); + None + } + } +} + +#[cfg(target_os = "macos")] +fn run_on_main<R: Send + 'static>( + app: &AppHandle, + f: impl FnOnce() -> R + Send + 'static, +) -> Result<R, String> { + if objc2::MainThreadMarker::new().is_some() { + return Ok(f()); + } + let (tx, rx) = std::sync::mpsc::channel(); + app.run_on_main_thread(move || { + let _ = tx.send(f()); + }) + .map_err(|e| e.to_string())?; + rx.recv().map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_schemes_a_webview_can_take() { + let http = parse_proxy("http://127.0.0.1:7890").unwrap(); + assert_eq!(http.scheme, ProxyScheme::Http); + assert_eq!(http.host, "127.0.0.1"); + assert_eq!(http.port, 7890); + assert_eq!(http.to_url_string(), "http://127.0.0.1:7890"); + + let socks = parse_proxy("socks5h://proxy.corp:1081").unwrap(); + assert_eq!(socks.scheme, ProxyScheme::Socks5); + assert_eq!(socks.to_url_string(), "socks5://proxy.corp:1081"); + + assert_eq!(parse_proxy("http://proxy.corp").unwrap().port, 80); + assert_eq!(parse_proxy("socks5://proxy.corp").unwrap().port, 1080); + assert_eq!(parse_proxy("http://[::1]:8080").unwrap().to_url_string(), "http://[::1]:8080"); + } + + #[test] + fn refuses_what_the_engines_cannot_express() { + assert!(parse_proxy("https://proxy.corp:443").unwrap_err().contains("https")); + assert!(parse_proxy("socks4://proxy.corp:1080").is_err()); + assert!(parse_proxy("http://:8080").is_err()); + assert!(parse_proxy("not a url").is_err()); + } + + #[test] + fn windows_arguments_are_the_whole_string() { + assert_eq!( + windows_browser_args(None), + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --auth-server-allowlist=" + ); + let proxy = parse_proxy("socks5://127.0.0.1:1080").unwrap(); + assert_eq!( + windows_browser_args(Some(&proxy)), + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --auth-server-allowlist= --proxy-server=socks5://127.0.0.1:1080" + ); + // Same input, same string: WebView2 rejects a second environment on + // the same user-data folder with different arguments. + assert_eq!(windows_browser_args(Some(&proxy)), windows_browser_args(Some(&proxy))); + } + + #[test] + fn identifier_is_a_version_5_uuid() { + // Version nibble 5, RFC 4122 variant — what uuid5() produces, so the + // constant was not typed by hand. + assert_eq!(DEFAULT_DATA_STORE_IDENTIFIER[6] >> 4, 5); + assert_eq!(DEFAULT_DATA_STORE_IDENTIFIER[8] & 0xc0, 0x80); + } + + #[test] + fn wire_names() { + let status = BrowserProxyStatus { + url: Some("http://127.0.0.1:7890".into()), + applies: ProxyApplies::NextTab, + reason: None, + }; + let json = serde_json::to_value(&status).unwrap(); + assert_eq!(json["applies"], "next-tab"); + assert_eq!(json["url"], "http://127.0.0.1:7890"); + } +} diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 0b23b78a97..4ef48f7cb6 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -14,14 +14,16 @@ use objc2::rc::Retained; use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject}; use objc2::{define_class, msg_send, sel, DeclaredClass, MainThreadMarker, MainThreadOnly}; use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; -use objc2_foundation::{ns_string, NSDate, NSDictionary, NSError, NSString}; +use objc2_foundation::{ns_string, NSArray, NSDate, NSDictionary, NSError, NSProcessInfo, NSString, NSUUID}; use objc2_web_kit::{ WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKSnapshotConfiguration, - WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebsiteDataStore, + WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebViewConfiguration, + WKWebsiteDataRecord, WKWebsiteDataStore, }; use tauri_runtime_wry::wry::{self, WebViewExtMacOS}; use super::super::channel::MessageSink; +use super::super::profile::{BrowserProxy, ProxyScheme, DEFAULT_DATA_STORE_IDENTIFIER}; pub const WORLD_NAME: &str = "codeg"; pub const HANDLER_NAME: &str = "codegBrowser"; @@ -259,23 +261,6 @@ pub fn stop_loading(webview: &wry::WebView) { /// Identity of the platform webview behind a wry `WebView`, matching the /// `source` a message sink receives. -/// Remove every kind of website data (cookies, caches, storage, …) from the -/// default data store — the one all browser tabs share, whether or not any -/// tab is open right now. `done` runs on the main thread once WebKit has -/// finished. -pub fn clear_default_data_store(done: impl Fn() + 'static) -> Result<(), String> { - let mtm = MainThreadMarker::new().ok_or("not on the main thread")?; - // SAFETY: main thread; WebKit owns every object handed back. - unsafe { - let store = WKWebsiteDataStore::defaultDataStore(mtm); - let types = WKWebsiteDataStore::allWebsiteDataTypes(mtm); - let since = NSDate::dateWithTimeIntervalSince1970(0.0); - let handler = RcBlock::new(done); - store.removeDataOfTypes_modifiedSince_completionHandler(&types, &since, &handler); - } - Ok(()) -} - /// `WKWebView.URL`, or `None` before any navigation has committed (and after /// a first navigation failed). wry's own `url()` unwraps this and panics. pub fn current_url(webview: &wry::WebView) -> Option<String> { @@ -322,3 +307,288 @@ pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { "frame": frame, }) } + +// --------------------------------------------------------------------------- +// Profile: the tabs' own data store and its proxy +// --------------------------------------------------------------------------- + +struct ProfileStore { + store: Retained<WKWebsiteDataStore>, + /// A store of the profile's own (macOS 14+) rather than WebKit's default + /// store, which the app's own webviews live in. + isolated: bool, + /// Proxy last written to the store, to skip rewriting the same value. + proxy: Option<BrowserProxy>, +} + +thread_local! { + static PROFILE: RefCell<Option<ProfileStore>> = const { RefCell::new(None) }; +} + +fn macos_major_version() -> isize { + NSProcessInfo::processInfo().operatingSystemVersion().majorVersion +} + +/// `WKWebsiteDataStore(forIdentifier:)` and `proxyConfigurations` both arrived +/// in macOS 14; before that the tabs share WebKit's default store with the app +/// and cannot be proxied. Safe from any thread. +pub fn supports_isolated_profile() -> bool { + macos_major_version() >= 14 +} + +/// The profile's data store, created on first use and kept for the life of +/// the main thread. WebKit hands back the same store for the same identifier, +/// so owned windows built by tauri with that identifier share it too. +fn profile_store(mtm: MainThreadMarker) -> Retained<WKWebsiteDataStore> { + PROFILE.with(|slot| { + let mut slot = slot.borrow_mut(); + let entry = slot.get_or_insert_with(|| { + let isolated = supports_isolated_profile(); + // SAFETY: main thread; WebKit owns the store. + let store = unsafe { + if isolated { + let identifier = NSUUID::from_bytes(DEFAULT_DATA_STORE_IDENTIFIER); + WKWebsiteDataStore::dataStoreForIdentifier(&identifier, mtm) + } else { + WKWebsiteDataStore::defaultDataStore(mtm) + } + }; + ProfileStore { + store, + isolated, + proxy: None, + } + }); + entry.store.clone() + }) +} + +fn profile_is_isolated() -> bool { + PROFILE.with(|slot| slot.borrow().as_ref().map(|entry| entry.isolated).unwrap_or(false)) +} + +/// A `WKWebViewConfiguration` whose data store is the profile's. Every regular +/// tab is built from one; popups inherit their opener's instead. +pub fn profile_configuration(mtm: MainThreadMarker) -> Retained<WKWebViewConfiguration> { + let store = profile_store(mtm); + // SAFETY: main thread; both objects are live. + unsafe { + let configuration = WKWebViewConfiguration::new(mtm); + configuration.setWebsiteDataStore(&store); + configuration + } +} + +/// Create the profile's store if needed and point it at `proxy` (or at no +/// proxy). Open tabs use the new value for their next connections; setting the +/// same value again does nothing, so callers can be liberal. +pub fn ensure_profile(proxy: Option<BrowserProxy>) -> Result<(), String> { + let mtm = mtm()?; + let store = profile_store(mtm); + let unchanged = PROFILE.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|entry| entry.proxy == proxy) + }); + if unchanged { + return Ok(()); + } + if !profile_is_isolated() { + return match proxy { + Some(_) => Err("proxying browser tabs needs macOS 14 or later".to_string()), + None => Ok(()), + }; + } + let configurations: Retained<NSArray<NSObject>> = match &proxy { + Some(proxy) => NSArray::from_retained_slice(&[network::proxy_config(proxy)?]), + None => NSArray::new(), + }; + // SAFETY: main thread; `proxyConfigurations` is a public property on + // macOS 14+ (checked above). Written through KVC because objc2-web-kit + // does not bind Network.framework's types. + unsafe { + let _: () = msg_send![&*store, setValue: &*configurations, forKey: ns_string!("proxyConfigurations")]; + } + PROFILE.with(|slot| { + if let Some(entry) = slot.borrow_mut().as_mut() { + entry.proxy = proxy; + } + }); + Ok(()) +} + +/// Remove every kind of website data (cookies, caches, storage, …) from the +/// profile, whether or not a tab is open. With a store of its own (macOS 14+) +/// that is the whole store; when the tabs still share WebKit's default store +/// with the app, see `clear_shared_store_except_app`. `done` runs on the main +/// thread once WebKit has finished. +pub fn clear_profile_store(done: impl Fn() + 'static) -> Result<(), String> { + let mtm = mtm()?; + let store = profile_store(mtm); + if !profile_is_isolated() { + return clear_shared_store_except_app(done); + } + // SAFETY: main thread; WebKit owns every object handed back. + unsafe { + let types = WKWebsiteDataStore::allWebsiteDataTypes(mtm); + let since = NSDate::dateWithTimeIntervalSince1970(0.0); + let handler = RcBlock::new(done); + store.removeDataOfTypes_modifiedSince_completionHandler(&types, &since, &handler); + } + Ok(()) +} + +/// Clear WebKit's default store one origin at a time, leaving the app's own +/// origins alone: below macOS 14 the tabs have no store of their own, and a +/// blanket removal would wipe the workspace's localStorage along with the +/// pages' cookies. +pub fn clear_shared_store_except_app(done: impl Fn() + 'static) -> Result<(), String> { + let mtm = mtm()?; + // SAFETY: main thread; WebKit owns the store. + let store = unsafe { WKWebsiteDataStore::defaultDataStore(mtm) }; + let types = unsafe { WKWebsiteDataStore::allWebsiteDataTypes(mtm) }; + let done = std::rc::Rc::new(done); + let removing_store = store.clone(); + let removing_types = types.clone(); + let fetched = RcBlock::new(move |records: std::ptr::NonNull<NSArray<WKWebsiteDataRecord>>| { + // SAFETY: WebKit passes a live array on the main thread. + let records = unsafe { records.as_ref() }; + let victims: Vec<Retained<WKWebsiteDataRecord>> = records + .iter() + .filter(|record| { + // SAFETY: live record. + let name = unsafe { record.displayName() }; + !is_app_origin(&name.to_string()) + }) + .collect(); + let victims = NSArray::from_retained_slice(&victims); + let done = done.clone(); + let finished = RcBlock::new(move || done()); + // SAFETY: main thread; all three arguments are live. + unsafe { + removing_store.removeDataOfTypes_forDataRecords_completionHandler( + &removing_types, + &victims, + &finished, + ); + } + }); + // SAFETY: main thread. + unsafe { store.fetchDataRecordsOfTypes_completionHandler(&types, &fetched) }; + Ok(()) +} + +/// Display names of every record in WebKit's default store (dev puppet only: +/// evidence that the per-record path spares the app's origins). +pub fn default_store_record_names(done: impl Fn(Vec<String>) + 'static) -> Result<(), String> { + let mtm = mtm()?; + // SAFETY: main thread; WebKit owns the store. + let store = unsafe { WKWebsiteDataStore::defaultDataStore(mtm) }; + let types = unsafe { WKWebsiteDataStore::allWebsiteDataTypes(mtm) }; + let fetched = RcBlock::new(move |records: std::ptr::NonNull<NSArray<WKWebsiteDataRecord>>| { + // SAFETY: WebKit passes a live array on the main thread. + let records = unsafe { records.as_ref() }; + let names = records + .iter() + // SAFETY: live record. + .map(|record| unsafe { record.displayName() }.to_string()) + .collect(); + done(names); + }); + // SAFETY: main thread. + unsafe { store.fetchDataRecordsOfTypes_completionHandler(&types, &fetched) }; + Ok(()) +} + +/// Hosts the app's own webviews are served from — `http://localhost:<port>` +/// in development, `tauri://localhost` and `http://tauri.localhost` in release +/// — as WebKit names their data records. +fn is_app_origin(display_name: &str) -> bool { + display_name == "localhost" || display_name.ends_with(".localhost") +} + +mod network { + //! Network.framework's proxy-config C API, resolved at run time: the + //! symbols exist only on macOS 14+, and a load-time reference would keep + //! the whole binary from launching on older systems. The objects it hands + //! back are Objective-C objects (`OS_object`), which is what lets them + //! ride in an `NSArray` and be retained like any other. + + use std::ffi::{c_char, c_void, CString}; + + use objc2::rc::Retained; + use objc2::runtime::NSObject; + + use super::{BrowserProxy, ProxyScheme}; + + type CreateHost = unsafe extern "C" fn(*const c_char, *const c_char) -> *mut NSObject; + type CreateSocks5 = unsafe extern "C" fn(*mut NSObject) -> *mut NSObject; + type CreateHttpConnect = unsafe extern "C" fn(*mut NSObject, *mut NSObject) -> *mut NSObject; + type AddExcludedDomain = unsafe extern "C" fn(*mut NSObject, *const c_char); + + /// Connections to these hosts never go through the proxy: pages served + /// from this machine (dev servers, codeg's own bridges) are the point of + /// the built-in browser and must keep working whatever the proxy would do + /// with them — the exception every browser and the `NO_PROXY` convention + /// make. (A remote-egress profile will want the opposite; it gets its own + /// configuration.) + const EXCLUDED_DOMAINS: [&str; 3] = ["localhost", "127.0.0.1", "::1"]; + + fn symbol(name: &str) -> Result<*mut c_void, String> { + let c_name = CString::new(name).map_err(|e| e.to_string())?; + // SAFETY: a valid C string; RTLD_DEFAULT searches every loaded image. + let pointer = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c_name.as_ptr()) }; + if pointer.is_null() { + Err(format!("{name} is not available on this macOS")) + } else { + Ok(pointer) + } + } + + pub fn proxy_config(proxy: &BrowserProxy) -> Result<Retained<NSObject>, String> { + // SAFETY: the signatures are Network.framework's declared ones. + let (create_host, create_socks5, create_http_connect, add_excluded_domain) = unsafe { + ( + std::mem::transmute::<*mut c_void, CreateHost>(symbol("nw_endpoint_create_host")?), + std::mem::transmute::<*mut c_void, CreateSocks5>(symbol("nw_proxy_config_create_socksv5")?), + std::mem::transmute::<*mut c_void, CreateHttpConnect>(symbol("nw_proxy_config_create_http_connect")?), + std::mem::transmute::<*mut c_void, AddExcludedDomain>(symbol("nw_proxy_config_add_excluded_domain")?), + ) + }; + let host = CString::new(proxy.host.as_str()).map_err(|e| format!("proxy host: {e}"))?; + let port = CString::new(proxy.port.to_string()).map_err(|e| format!("proxy port: {e}"))?; + // SAFETY: valid C strings; every `create` follows the create rule + // (+1), which `Retained::from_raw` takes over. + unsafe { + let endpoint = Retained::from_raw(create_host(host.as_ptr(), port.as_ptr())) + .ok_or_else(|| format!("cannot describe proxy endpoint {}:{}", proxy.host, proxy.port))?; + let endpoint_ptr = Retained::as_ptr(&endpoint) as *mut NSObject; + let config = match proxy.scheme { + ProxyScheme::Http => create_http_connect(endpoint_ptr, std::ptr::null_mut()), + ProxyScheme::Socks5 => create_socks5(endpoint_ptr), + }; + let config = Retained::from_raw(config).ok_or("cannot create proxy configuration")?; + for domain in EXCLUDED_DOMAINS { + let domain = CString::new(domain).expect("static"); + add_excluded_domain(Retained::as_ptr(&config) as *mut NSObject, domain.as_ptr()); + } + Ok(config) + } + } +} + +#[cfg(test)] +mod tests { + use super::is_app_origin; + + /// The per-record clear must spare every host the app's own webviews are + /// served from and nothing else. + #[test] + fn app_origins_are_recognised_by_record_name() { + assert!(is_app_origin("localhost")); + assert!(is_app_origin("tauri.localhost")); + assert!(!is_app_origin("example.com")); + assert!(!is_app_origin("127.0.0.1")); + assert!(!is_app_origin("localhost.example.com")); + } +} diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index a453fb0b1b..5351f62a70 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -113,12 +113,10 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { "position": w.outer_position().ok().map(|p| [p.x, p.y]), "size": w.inner_size().ok().map(|s| [s.width, s.height]), "scale": w.scale_factor().ok(), - // Never `url()` a browser window: see `BrowserSurface::url`. - "url": if w.label().starts_with(crate::browser::TAB_LABEL_PREFIX) { - None - } else { - w.url().ok().map(|u| u.to_string()) - }, + // Never read a URL through tauri: wry panics on a + // webview without a committed document, and even the + // workspace window is one while its first page loads. + "url": Value::Null, }) }) .collect(); @@ -368,6 +366,38 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .collect(); Ok(Value::Array(gestures)) } + "browser_profile" => Ok(json!({ + "isolatedStorage": crate::browser::profile::isolated_storage(), + "proxy": crate::browser::profile::proxy_status(), + "effectiveProxyUrl": crate::network::proxy::effective_proxy_url(), + })), + // The per-record path older macOS uses, run against the shared + // default store here so it can be exercised on a machine that has an + // isolated profile. + #[cfg(target_os = "macos")] + "browser_default_store_records" => { + let names = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new())); + let sink = names.clone(); + browser_commands::on_main_until_done(app, "list default store records", move |done| { + crate::browser::shim::macos::default_store_record_names(move |found| { + *sink.lock().unwrap_or_else(|p| p.into_inner()) = found; + done(); + }) + }) + .await + .map_err(err_string)?; + let names = names.lock().unwrap_or_else(|p| p.into_inner()).clone(); + Ok(json!(names)) + } + #[cfg(target_os = "macos")] + "browser_clear_shared_records" => { + browser_commands::on_main_until_done(app, "clear shared records", |done| { + crate::browser::shim::macos::clear_shared_store_except_app(done) + }) + .await + .map_err(err_string)?; + Ok(Value::Null) + } "browser_clear_data" => { browser_commands::clear_data_core(app, ®istry) .await @@ -390,8 +420,14 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { Ok(Value::Null) } // Evaluate in the MAIN (workspace) webview — drives the frontend. + // `label` picks another app window (settings, …) instead. "main_eval" => { - let main = main_window()?; + let main = match cmd.get("label").and_then(Value::as_str) { + Some(label) => app + .get_webview_window(label) + .ok_or_else(|| format!("no window {label:?}"))?, + None => main_window()?, + }; let js = str_arg(cmd, "js")?; let (tx, rx) = std::sync::mpsc::channel::<String>(); main.eval_with_callback(&js, move |value| { diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 3fe112ce54..5d41ca59c2 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -27,11 +27,15 @@ use std::thread::ThreadId; use std::time::Duration; use tauri::{AppHandle, Manager, Url, WebviewWindow}; +#[cfg(target_os = "windows")] +use tauri_runtime_wry::wry::WebContext; use tauri_runtime_wry::wry::{ self, dpi, NewWindowFeatures, NewWindowResponse, PageLoadEvent, Rect, WebViewBuilder, }; use super::channel::{self, MessageSink}; +#[cfg(target_os = "windows")] +use super::profile; use super::events; use super::hooks; use super::policy; @@ -393,6 +397,14 @@ type OpenerConfiguration = (); /// Main thread only. Builds the child webview at `bounds` with every hook /// attached and **no URL**: a regular tab is navigated by the caller once the /// page channel is installed, a popup is navigated by the engine itself. +#[cfg(target_os = "windows")] +thread_local! { + // WebView2 keeps a webview's cookies and storage in its environment's + // user-data folder; the profile's directory is that folder for every + // browser webview of this process. Kept alive like tauri keeps its own. + static WEB_CONTEXT: RefCell<Option<WebContext>> = const { RefCell::new(None) }; +} + #[allow(clippy::too_many_arguments)] fn build_child( app: &AppHandle, @@ -404,11 +416,62 @@ fn build_child( devtools: bool, configuration: Option<OpenerConfiguration>, ) -> Result<wry::WebView, String> { + #[cfg(target_os = "windows")] + { + WEB_CONTEXT.with(|slot| { + let mut slot = slot.borrow_mut(); + let context = slot.get_or_insert_with(|| { + WebContext::new(Some(profile::directory(profile::DEFAULT_PROFILE_ID))) + }); + configure_child( + WebViewBuilder::new_with_web_context(context), + app, + owner, + tab_id, + label, + bounds, + visible, + devtools, + configuration, + )? + .build_as_child(owner) + .map_err(|e| e.to_string()) + }) + } + #[cfg(not(target_os = "windows"))] + { + configure_child( + WebViewBuilder::new(), + app, + owner, + tab_id, + label, + bounds, + visible, + devtools, + configuration, + )? + .build_as_child(owner) + .map_err(|e| e.to_string()) + } +} + +#[allow(clippy::too_many_arguments)] +fn configure_child<'a>( + builder: WebViewBuilder<'a>, + app: &AppHandle, + owner: &WebviewWindow, + tab_id: &str, + label: &'a str, + bounds: Bounds, + visible: bool, + devtools: bool, + configuration: Option<OpenerConfiguration>, +) -> Result<WebViewBuilder<'a>, String> { let nav_id = tab_id.to_string(); let nav_app = app.clone(); let nav_owner = owner.label().to_string(); - #[allow(unused_mut)] - let mut builder = WebViewBuilder::new() + let mut builder = builder .with_id(label) .with_bounds(rect(bounds)) .with_visible(visible) @@ -464,13 +527,35 @@ fn build_child( .with_download_started_handler(|_url, _destination| false) .with_new_window_req_handler(new_window_handler(app.clone(), owner.clone(), tab_id.to_string())); #[cfg(target_os = "macos")] - if let Some(configuration) = configuration { + { use tauri_runtime_wry::wry::WebViewBuilderExtMacos; + // A popup keeps its opener's configuration (that is what preserves + // `window.opener`); a regular tab gets one whose data store is the + // browser profile's, so nothing a page stores lands in the app's own + // store and the profile's proxy applies. + let configuration = match configuration { + Some(configuration) => configuration, + None => { + let mtm = objc2::MainThreadMarker::new().ok_or("not on the main thread")?; + super::shim::macos::profile_configuration(mtm) + } + }; builder = builder.with_webview_configuration(configuration); } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + use tauri_runtime_wry::wry::WebViewBuilderExtWindows; + let _ = configuration; + // WebView2 takes the proxy (and everything else) from the environment's + // browser arguments: one string for every browser webview of the + // process, see `profile::windows_browser_args`. + builder = builder.with_additional_browser_args(profile::windows_browser_args( + profile::frozen_proxy().as_ref(), + )); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] let _ = configuration; - builder.build_as_child(owner).map_err(|e| e.to_string()) + Ok(builder) } /// Build the child webview for a regular tab. The caller navigates afterwards, diff --git a/src-tauri/src/browser/surface_window.rs b/src-tauri/src/browser/surface_window.rs index c162249ea9..be898c0cad 100644 --- a/src-tauri/src/browser/surface_window.rs +++ b/src-tauri/src/browser/surface_window.rs @@ -8,6 +8,7 @@ use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBui use super::events; use super::hooks; use super::policy; +use super::profile; use super::registry::BrowserRegistry; pub fn create( @@ -48,6 +49,34 @@ pub fn create( .disable_drag_drop_handler() .zoom_hotkeys_enabled(true) .browser_extensions_enabled(false); + // Same container and proxy as the embedded tabs, so a page behaves the + // same whichever surface hosts it. + #[cfg(target_os = "macos")] + // wry falls back to the default store below macOS 14, exactly like the + // shim does for embedded tabs; the proxy is a property of the store and + // is in place once `profile::prepare` has run. + let builder = builder.data_store_identifier(profile::DEFAULT_DATA_STORE_IDENTIFIER); + #[cfg(target_os = "windows")] + let builder = builder + .data_directory(profile::directory(profile::DEFAULT_PROFILE_ID)) + .additional_browser_args(&profile::windows_browser_args( + profile::frozen_proxy().as_ref(), + )); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let builder = { + let builder = builder.data_directory(profile::directory(profile::DEFAULT_PROFILE_ID)); + match profile::current_proxy() { + Ok(Some(proxy)) => match Url::parse(&proxy.to_url_string()) { + Ok(url) => builder.proxy_url(url), + Err(_) => builder, + }, + Ok(None) => builder, + Err(reason) => { + tracing::warn!("[browser] ignoring the configured proxy: {reason}"); + builder + } + } + }; let builder = builder.parent(owner)?; let window = builder.build()?; diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index 084bdeb51c..3ebabe7405 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -98,6 +98,9 @@ pub struct BrowserCapabilities { pub channel: ChannelKind, /// Human-readable reasons behind a degraded answer (for diagnostics UI). pub reasons: Vec<String>, + /// Browsing data lives apart from the app's own web storage. + pub isolated_storage: bool, + pub proxy: crate::browser::profile::BrowserProxyStatus, } /// Caller's surface preference for `browser_open_tab`. diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 6d2c4d3142..31a17436f7 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -59,6 +59,8 @@ pub fn capabilities() -> BrowserCapabilities { platform: platform_name().to_string(), channel: ChannelKind::Degraded, reasons, + isolated_storage: crate::browser::profile::isolated_storage(), + proxy: crate::browser::profile::proxy_status(), } } @@ -137,6 +139,8 @@ pub fn open_tab_core( } let url = parse_web_url(¶ms.url)?; let label = tab_label(¶ms.tab_id); + crate::browser::profile::prepare(app) + .map_err(|e| window_err("Failed to prepare the browser profile", e))?; let surface = match pick_surface(params.surface) { #[cfg(all( @@ -239,36 +243,19 @@ pub fn open_tab_core( pub async fn clear_data_core(app: &AppHandle, registry: &BrowserRegistry) -> Result<(), AppCommandError> { #[cfg(target_os = "macos")] { - // Straight at the shared default store: works with no tab open and - // reports completion, which a surface's `clear_all_browsing_data` - // cannot. + // Straight at the profile's store: works with no tab open and reports + // completion, which a surface's `clear_all_browsing_data` cannot. let _ = registry; - let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); - let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); - let finish = move |result: Result<(), String>| { - if let Some(tx) = tx.lock().unwrap_or_else(|p| p.into_inner()).take() { - let _ = tx.send(result); - } - }; - app.run_on_main_thread(move || { - let on_done = finish.clone(); - if let Err(err) = crate::browser::shim::macos::clear_default_data_store(move || on_done(Ok(()))) { - finish(Err(err)); - } + on_main_until_done(app, "Failed to clear browsing data", |done| { + crate::browser::shim::macos::clear_profile_store(done) }) - .map_err(|e| window_err("Failed to clear browsing data", e))?; - match tokio::time::timeout(std::time::Duration::from_secs(15), rx).await { - Ok(Ok(Ok(()))) => Ok(()), - Ok(Ok(Err(err))) => Err(window_err("Failed to clear browsing data", err)), - Ok(Err(_)) => Err(window_err("Failed to clear browsing data", "the request was dropped")), - Err(_) => Err(window_err("Failed to clear browsing data", "timed out waiting for WebKit")), - } + .await } #[cfg(not(target_os = "macos"))] { // Until the Windows / Linux shims land, clearing goes through a live - // surface (they all share the store); with none open there is nothing - // to call into. + // surface (they all share the profile); with none open there is + // nothing to call into. let _ = app; let Some(state) = registry.list().into_iter().next() else { return Err(AppCommandError::invalid_input( @@ -281,6 +268,37 @@ pub async fn clear_data_core(app: &AppHandle, registry: &BrowserRegistry) -> Res } } +/// Run `start` on the main thread and wait until the completion callback it +/// was given has fired (WebKit reports finished removals that way), with a +/// timeout so a callback that never comes cannot hang the caller. +#[cfg(target_os = "macos")] +pub async fn on_main_until_done( + app: &AppHandle, + what: &str, + start: impl FnOnce(Box<dyn Fn() + 'static>) -> Result<(), String> + Send + 'static, +) -> Result<(), AppCommandError> { + let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + let finish = move |result: Result<(), String>| { + if let Some(tx) = tx.lock().unwrap_or_else(|p| p.into_inner()).take() { + let _ = tx.send(result); + } + }; + app.run_on_main_thread(move || { + let on_done = finish.clone(); + if let Err(err) = start(Box::new(move || on_done(Ok(())))) { + finish(Err(err)); + } + }) + .map_err(|e| window_err(what, e))?; + match tokio::time::timeout(std::time::Duration::from_secs(15), rx).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(err))) => Err(window_err(what, err)), + Ok(Err(_)) => Err(window_err(what, "the request was dropped")), + Err(_) => Err(window_err(what, "timed out waiting for WebKit")), + } +} + fn surface_of(registry: &BrowserRegistry, tab_id: &str) -> Result<BrowserSurface, AppCommandError> { registry .surface(tab_id) diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index db3b685c0a..895b121834 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -273,6 +273,7 @@ pub async fn get_system_proxy_settings( #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn update_system_proxy_settings( + app: tauri::AppHandle, settings: SystemProxySettings, db: State<'_, AppDatabase>, ) -> Result<SystemProxySettings, AppCommandError> { @@ -287,6 +288,7 @@ pub async fn update_system_proxy_settings( .map_err(AppCommandError::from)?; proxy::apply_system_proxy_settings(&normalized)?; + crate::browser::profile::proxy_settings_changed(&app); Ok(normalized) } diff --git a/src-tauri/src/network/proxy.rs b/src-tauri/src/network/proxy.rs index 5e4c9954bb..1126e26cdc 100644 --- a/src-tauri/src/network/proxy.rs +++ b/src-tauri/src/network/proxy.rs @@ -166,6 +166,30 @@ pub(crate) fn proxy_env_vars_missing_scheme() -> Vec<String> { .collect() } +/// The one proxy URL a consumer that can take only one should use: the +/// process environment as the app's own HTTP clients and agent processes see +/// it. `HTTPS_PROXY` wins (most page traffic is TLS), then `ALL_PROXY`, then +/// `HTTP_PROXY`; codeg's setting writes all of them with one value, so the +/// order only matters for externally exported variables. A scheme-less value +/// is repaired the way [`normalize_proxy_url`] does; an unparsable one is +/// ignored. (Only the built-in browser reads it, hence desktop-only.) +#[cfg(feature = "tauri-runtime")] +pub fn effective_proxy_url() -> Option<String> { + const ORDER: [&str; 6] = [ + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + ]; + let vars = current_proxy_env_vars(); + ORDER + .iter() + .find_map(|key| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())) + .and_then(|raw| normalize_proxy_url(&raw).ok()) +} + pub fn current_proxy_env_vars() -> Vec<(String, String)> { PROXY_ENV_KEYS .iter() diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index d7f76631e8..7633c56111 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; const CODEG_DIR_NAME: &str = ".codeg"; const PETS_DIR_NAME: &str = "pets"; +const BROWSER_PROFILES_DIR_NAME: &str = "browser-profiles"; const UPLOADS_DIR_NAME: &str = "uploads"; const LOGS_DIR_NAME: &str = "logs"; const TURN_TIMINGS_DIR_NAME: &str = "turn-timings"; @@ -47,6 +48,23 @@ pub fn codeg_pets_root() -> PathBuf { .unwrap_or_else(|| PathBuf::from(CODEG_DIR_NAME).join(PETS_DIR_NAME)) } +/// Root directory for built-in browser profiles (WebView2 user-data folders +/// on Windows, WebKitGTK data directories on Linux; macOS keeps profiles in +/// WebKit's own store and never reads this). +/// +/// Resolution order matches `codeg_pets_root()`. +pub fn codeg_browser_profiles_root() -> PathBuf { + if let Some(custom) = std::env::var_os("CODEG_HOME").filter(|s| !s.is_empty()) { + return PathBuf::from(custom).join(BROWSER_PROFILES_DIR_NAME); + } + if let Some(data) = std::env::var_os("CODEG_DATA_DIR").filter(|s| !s.is_empty()) { + return PathBuf::from(data).join(BROWSER_PROFILES_DIR_NAME); + } + dirs::home_dir() + .map(|h| h.join(CODEG_DIR_NAME).join(BROWSER_PROFILES_DIR_NAME)) + .unwrap_or_else(|| PathBuf::from(CODEG_DIR_NAME).join(BROWSER_PROFILES_DIR_NAME)) +} + /// Root directory for attachments uploaded from the web client. /// /// Resolution order matches `codeg_pets_root()`: diff --git a/src-tauri/src/web/handlers/system_settings.rs b/src-tauri/src/web/handlers/system_settings.rs index 7595781575..b7d8c0c8ac 100644 --- a/src-tauri/src/web/handlers/system_settings.rs +++ b/src-tauri/src/web/handlers/system_settings.rs @@ -102,6 +102,10 @@ pub async fn update_system_proxy_settings( .map_err(AppCommandError::from)?; proxy::apply_system_proxy_settings(&settings)?; + #[cfg(feature = "tauri-runtime")] + if let crate::web::event_bridge::EventEmitter::Tauri(app) = &state.emitter { + crate::browser::profile::proxy_settings_changed(app); + } Ok(Json(settings)) } diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index e0375e9505..8ef29eb1b3 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => { platform: "macos", channel: "native", reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live", reason: null }, }) ), subscribe: vi.fn((event: string, handler: Handler) => { @@ -211,6 +213,8 @@ describe("BrowserEventsBridge", () => { platform: "web", channel: "degraded", reasons: ["web"], + isolatedStorage: false, + proxy: { url: null, applies: "unsupported", reason: null }, }) render(<BrowserEventsBridge />) await flush() diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index 2b09490a7e..a1b5ee8226 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ browserClearData: vi.fn(), + browserCapabilitiesNow: vi.fn(), toast: { success: vi.fn(), error: vi.fn() }, })) vi.mock("@/lib/browser/browser-api", () => ({ browserClearData: mocks.browserClearData, + browserCapabilitiesNow: mocks.browserCapabilitiesNow, })) vi.mock("@/lib/platform", () => ({ isDesktop: () => true })) vi.mock("sonner", () => ({ toast: mocks.toast })) @@ -34,9 +36,29 @@ function expandSection() { fireEvent.click(screen.getByRole("button", { name: "Built-in browser" })) } +function capabilitiesWith(proxy: { + url: string | null + applies: "live" | "next-tab" | "restart" | "unsupported" + reason: string | null +}) { + return { + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + isolatedStorage: true, + proxy, + } +} + beforeEach(() => { resetBrowserPrefsForTests() mocks.browserClearData.mockReset() + mocks.browserCapabilitiesNow.mockReset() + mocks.browserCapabilitiesNow.mockResolvedValue( + capabilitiesWith({ url: null, applies: "live", reason: null }) + ) mocks.toast.success.mockReset() mocks.toast.error.mockReset() }) @@ -102,6 +124,53 @@ describe("BrowserSettingsSection", () => { ) }) + it("shows the proxy browser tabs use, fetched when the section opens", async () => { + mocks.browserCapabilitiesNow.mockResolvedValue( + capabilitiesWith({ + url: "http://127.0.0.1:7890", + applies: "live", + reason: null, + }) + ) + renderSection() + expect(mocks.browserCapabilitiesNow).not.toHaveBeenCalled() + expandSection() + expect( + await screen.findByText("Using http://127.0.0.1:7890") + ).toBeInTheDocument() + expect(screen.queryByText(/Restart codeg/)).not.toBeInTheDocument() + }) + + it("explains when the proxy needs a restart or is not usable", async () => { + mocks.browserCapabilitiesNow.mockResolvedValue( + capabilitiesWith({ + url: "socks5://10.0.0.1:1080", + applies: "restart", + reason: "restart codeg for browser tabs to use the new proxy", + }) + ) + const { unmount } = renderSection() + expandSection() + expect( + await screen.findByText("Using socks5://10.0.0.1:1080") + ).toBeInTheDocument() + expect(screen.getByText(/Restart codeg/)).toBeInTheDocument() + unmount() + + mocks.browserCapabilitiesNow.mockResolvedValue( + capabilitiesWith({ + url: null, + applies: "live", + reason: "the built-in browser cannot use a https:// proxy", + }) + ) + renderSection() + expandSection() + expect( + await screen.findByText(/not one browser tabs can use/) + ).toBeInTheDocument() + }) + it("keeps the dialog and reports the failure when clearing fails", async () => { mocks.browserClearData.mockRejectedValue(new Error("WebKit said no")) renderSection() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index bfa9f8bec9..d5640b8773 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -12,9 +12,9 @@ * link goes to the system browser and none of this exists. */ -import { useState } from "react" +import { useEffect, useState } from "react" import { useTranslations } from "next-intl" -import { AppWindow, Eraser, Globe, Link2, Wrench } from "lucide-react" +import { AppWindow, Eraser, Globe, Link2, Network, Wrench } from "lucide-react" import { toast } from "sonner" import { SettingCard, SettingRow } from "@/components/shared/setting-card" @@ -39,7 +39,10 @@ import { } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { toErrorMessage } from "@/lib/app-error" -import { browserClearData } from "@/lib/browser/browser-api" +import { + browserCapabilitiesNow, + browserClearData, +} from "@/lib/browser/browser-api" import { LINK_SOURCES, setBrowserDevtools, @@ -50,6 +53,7 @@ import { type LinkTarget, type SurfaceOverride, } from "@/lib/browser/browser-prefs" +import type { BrowserProxyStatus } from "@/lib/browser/types" import { isDesktop } from "@/lib/platform" // Literal message keys per id — next-intl only resolves literal keys, so the @@ -75,6 +79,27 @@ const SURFACE_LABEL_KEYS = { window: "surfaceWindow", } as const satisfies Record<SurfaceOverride, string> +/** + * One line for the proxy row: what browser tabs use right now and, where the + * platform cannot switch live, what a change needs. Read-only — the proxy is + * set in System settings, not here. + */ +export function proxyStatusLines( + t: ReturnType<typeof useTranslations<"BrowserSettings">>, + status: BrowserProxyStatus | null +): string[] { + if (!status) return [] + if (status.applies === "unsupported") return [t("proxyUnsupported")] + const lines: string[] = [] + if (status.url) lines.push(t("proxyOn", { url: status.url })) + else if (status.reason) lines.push(t("proxyUnusable")) + else lines.push(t("proxyOff")) + if (status.applies === "restart" && status.reason) + lines.push(t("proxyRestart")) + if (status.applies === "next-tab") lines.push(t("proxyNextTab")) + return lines +} + export function BrowserSettingsSection() { const t = useTranslations("BrowserSettings") const prefs = useBrowserPrefs() @@ -83,6 +108,24 @@ export function BrowserSettingsSection() { const [expanded, setExpanded] = useState(false) const [confirmClear, setConfirmClear] = useState(false) const [clearing, setClearing] = useState(false) + const [proxy, setProxy] = useState<BrowserProxyStatus | null>(null) + + // Fetched when the section opens (not once per app run): the answer follows + // the proxy setting, which lives on another settings page. + useEffect(() => { + if (!expanded) return + let cancelled = false + browserCapabilitiesNow() + .then((caps) => { + if (!cancelled) setProxy(caps.proxy) + }) + .catch(() => { + if (!cancelled) setProxy(null) + }) + return () => { + cancelled = true + } + }, [expanded]) if (!isDesktop()) return null @@ -195,6 +238,19 @@ export function BrowserSettingsSection() { </Select> } /> + <SettingRow + icon={Network} + title={t("proxyTitle")} + description={t("proxyHint")} + > + <div className="space-y-0.5 text-xs text-muted-foreground"> + {proxyStatusLines(t, proxy).map((line) => ( + <p key={line} className="break-all"> + {line} + </p> + ))} + </div> + </SettingRow> <SettingRow icon={Eraser} title={t("clearTitle")} diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx index 6f07b2bc56..8d8bbb7056 100644 --- a/src/hooks/use-open-url-target.test.tsx +++ b/src/hooks/use-open-url-target.test.tsx @@ -44,6 +44,8 @@ const AVAILABLE = { platform: "macos", channel: "native" as const, reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live" as const, reason: null }, } describe("useOpenUrlTarget", () => { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 536c50e317..fd68874c3d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "مسح", "cancel": "إلغاء", "cleared": "تم مسح بيانات التصفح", - "clearFailed": "تعذّر مسح بيانات التصفح: {message}" + "clearFailed": "تعذّر مسح بيانات التصفح: {message}", + "proxyTitle": "وكيل الشبكة", + "proxyHint": "تستخدم علامات تبويب المتصفح الوكيل المحدد في إعدادات النظام. عناوين هذا الجهاز (localhost) لا تمر عبر الوكيل أبدًا.", + "proxyOn": "يُستخدم {url}", + "proxyOff": "اتصال مباشر", + "proxyNextTab": "يسري تغيير الوكيل على علامات التبويب التي تُفتح من الآن فصاعدًا.", + "proxyRestart": "أعد تشغيل codeg لتستخدم علامات تبويب المتصفح الوكيل المتغيّر.", + "proxyUnsupported": "غير متاح في هذا الإصدار من macOS (يلزم الإصدار 14 أو أحدث).", + "proxyUnusable": "الوكيل المُعدّ ليس من نوع يمكن لعلامات تبويب المتصفح استخدامه (http وsocks5 فقط)." }, "LogsSettings": { "loading": "جارٍ التحميل…", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 07ea98b177..10c5c96f6d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "Löschen", "cancel": "Abbrechen", "cleared": "Browserdaten gelöscht", - "clearFailed": "Browserdaten konnten nicht gelöscht werden: {message}" + "clearFailed": "Browserdaten konnten nicht gelöscht werden: {message}", + "proxyTitle": "Netzwerk-Proxy", + "proxyHint": "Browser-Tabs verwenden den Proxy aus den Systemeinstellungen. Adressen auf diesem Rechner (localhost) laufen nie über den Proxy.", + "proxyOn": "Verwendet {url}", + "proxyOff": "Direkte Verbindung", + "proxyNextTab": "Ein geänderter Proxy gilt für ab jetzt geöffnete Tabs.", + "proxyRestart": "Starte codeg neu, damit Browser-Tabs den geänderten Proxy verwenden.", + "proxyUnsupported": "Auf dieser macOS-Version nicht verfügbar (14 oder neuer erforderlich).", + "proxyUnusable": "Der konfigurierte Proxy ist kein Typ, den Browser-Tabs verwenden können (nur http und socks5)." }, "LogsSettings": { "loading": "Wird geladen…", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 99a677bf74..44fdc97e9f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "Clear", "cancel": "Cancel", "cleared": "Browsing data cleared", - "clearFailed": "Could not clear browsing data: {message}" + "clearFailed": "Could not clear browsing data: {message}", + "proxyTitle": "Network proxy", + "proxyHint": "Browser tabs use the proxy from System settings. Addresses on this machine (localhost) are never proxied.", + "proxyOn": "Using {url}", + "proxyOff": "Direct connection", + "proxyNextTab": "A changed proxy applies to tabs opened from now on.", + "proxyRestart": "Restart codeg for browser tabs to use the changed proxy.", + "proxyUnsupported": "Not available on this version of macOS (14 or later is needed).", + "proxyUnusable": "The configured proxy is not one browser tabs can use (http and socks5 only)." }, "LogsSettings": { "loading": "Loading…", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 927aefd93b..915076b070 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "Borrar", "cancel": "Cancelar", "cleared": "Datos de navegación borrados", - "clearFailed": "No se pudieron borrar los datos de navegación: {message}" + "clearFailed": "No se pudieron borrar los datos de navegación: {message}", + "proxyTitle": "Proxy de red", + "proxyHint": "Las pestañas del navegador usan el proxy de Ajustes del sistema. Las direcciones de este equipo (localhost) nunca pasan por el proxy.", + "proxyOn": "Usando {url}", + "proxyOff": "Conexión directa", + "proxyNextTab": "Un cambio de proxy se aplica a las pestañas que se abran a partir de ahora.", + "proxyRestart": "Reinicia codeg para que las pestañas del navegador usen el proxy cambiado.", + "proxyUnsupported": "No disponible en esta versión de macOS (se necesita 14 o posterior).", + "proxyUnusable": "El proxy configurado no es de un tipo que las pestañas del navegador puedan usar (solo http y socks5)." }, "LogsSettings": { "loading": "Cargando…", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 087a2fe7d6..088dc7ee51 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "Effacer", "cancel": "Annuler", "cleared": "Données de navigation effacées", - "clearFailed": "Impossible d’effacer les données de navigation : {message}" + "clearFailed": "Impossible d’effacer les données de navigation : {message}", + "proxyTitle": "Proxy réseau", + "proxyHint": "Les onglets du navigateur utilisent le proxy des Réglages système. Les adresses de cette machine (localhost) ne passent jamais par le proxy.", + "proxyOn": "Utilise {url}", + "proxyOff": "Connexion directe", + "proxyNextTab": "Un changement de proxy s’applique aux onglets ouverts à partir de maintenant.", + "proxyRestart": "Redémarrez codeg pour que les onglets du navigateur utilisent le proxy modifié.", + "proxyUnsupported": "Indisponible sur cette version de macOS (14 ou ultérieure requise).", + "proxyUnusable": "Le proxy configuré n’est pas d’un type utilisable par les onglets du navigateur (http et socks5 uniquement)." }, "LogsSettings": { "loading": "Chargement…", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index cfc53bd8de..101148280d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "消去", "cancel": "キャンセル", "cleared": "閲覧データを消去しました", - "clearFailed": "閲覧データを消去できませんでした: {message}" + "clearFailed": "閲覧データを消去できませんでした: {message}", + "proxyTitle": "ネットワークプロキシ", + "proxyHint": "ブラウザタブは「システム設定」のプロキシを使います。このマシン上のアドレス(localhost)はプロキシを経由しません。", + "proxyOn": "{url} を使用中", + "proxyOff": "直接接続", + "proxyNextTab": "プロキシの変更は、これ以降に開くタブに適用されます。", + "proxyRestart": "変更したプロキシをブラウザタブで使うには codeg を再起動してください。", + "proxyUnsupported": "このバージョンの macOS では利用できません(macOS 14 以降が必要です)。", + "proxyUnusable": "設定されたプロキシはブラウザタブで使用できない種類です(http と socks5 のみ対応)。" }, "LogsSettings": { "loading": "読み込み中…", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 83e4ce7b88..b2f2709a35 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "지우기", "cancel": "취소", "cleared": "브라우징 데이터를 지웠습니다", - "clearFailed": "브라우징 데이터를 지울 수 없습니다: {message}" + "clearFailed": "브라우징 데이터를 지울 수 없습니다: {message}", + "proxyTitle": "네트워크 프록시", + "proxyHint": "브라우저 탭은 「시스템 설정」의 프록시를 사용합니다. 이 컴퓨터의 주소(localhost)는 프록시를 거치지 않습니다.", + "proxyOn": "{url} 사용 중", + "proxyOff": "직접 연결", + "proxyNextTab": "변경된 프록시는 이후에 여는 탭부터 적용됩니다.", + "proxyRestart": "변경된 프록시를 브라우저 탭에서 사용하려면 codeg를 다시 시작하세요.", + "proxyUnsupported": "이 macOS 버전에서는 사용할 수 없습니다(macOS 14 이상 필요).", + "proxyUnusable": "설정된 프록시는 브라우저 탭에서 사용할 수 없는 종류입니다(http 및 socks5만 지원)." }, "LogsSettings": { "loading": "불러오는 중…", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c181bd1b0d..bd1b24b935 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "Limpar", "cancel": "Cancelar", "cleared": "Dados de navegação limpos", - "clearFailed": "Não foi possível limpar os dados de navegação: {message}" + "clearFailed": "Não foi possível limpar os dados de navegação: {message}", + "proxyTitle": "Proxy de rede", + "proxyHint": "Os separadores do navegador usam o proxy das Definições do sistema. Os endereços desta máquina (localhost) nunca passam pelo proxy.", + "proxyOn": "A usar {url}", + "proxyOff": "Ligação direta", + "proxyNextTab": "Uma alteração de proxy aplica-se aos separadores abertos a partir de agora.", + "proxyRestart": "Reinicie o codeg para os separadores do navegador usarem o proxy alterado.", + "proxyUnsupported": "Indisponível nesta versão do macOS (é necessária a 14 ou posterior).", + "proxyUnusable": "O proxy configurado não é de um tipo que os separadores do navegador possam usar (apenas http e socks5)." }, "LogsSettings": { "loading": "Carregando…", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index a0fe921335..e3d36b5c81 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "清除", "cancel": "取消", "cleared": "浏览数据已清除", - "clearFailed": "无法清除浏览数据:{message}" + "clearFailed": "无法清除浏览数据:{message}", + "proxyTitle": "网络代理", + "proxyHint": "浏览器标签页使用「系统设置」里的代理。本机地址(localhost)永远不走代理。", + "proxyOn": "正在使用 {url}", + "proxyOff": "直接连接", + "proxyNextTab": "代理变更对此后打开的标签页生效。", + "proxyRestart": "代理已变更,重启 codeg 后浏览器标签页才会使用新代理。", + "proxyUnsupported": "当前 macOS 版本不支持(需要 macOS 14 或更高)。", + "proxyUnusable": "配置的代理不是浏览器标签页能使用的类型(仅支持 http 和 socks5)。" }, "LogsSettings": { "loading": "加载中…", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5fac964c1c..82c55b59ae 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4752,7 +4752,15 @@ "clearConfirmAction": "清除", "cancel": "取消", "cleared": "已清除瀏覽資料", - "clearFailed": "無法清除瀏覽資料:{message}" + "clearFailed": "無法清除瀏覽資料:{message}", + "proxyTitle": "網路代理", + "proxyHint": "瀏覽器分頁使用「系統設定」中的代理。本機位址(localhost)永遠不走代理。", + "proxyOn": "正在使用 {url}", + "proxyOff": "直接連線", + "proxyNextTab": "代理變更對此後開啟的分頁生效。", + "proxyRestart": "代理已變更,重新啟動 codeg 後瀏覽器分頁才會使用新代理。", + "proxyUnsupported": "目前的 macOS 版本不支援(需要 macOS 14 或更新)。", + "proxyUnusable": "設定的代理不是瀏覽器分頁能使用的類型(僅支援 http 與 socks5)。" }, "LogsSettings": { "loading": "載入中…", diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index d61b2902c9..a26db2458c 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -18,6 +18,8 @@ const UNAVAILABLE: BrowserCapabilities = { platform: "web", channel: "degraded", reasons: ["built-in browser needs the desktop runtime"], + isolatedStorage: false, + proxy: { url: null, applies: "unsupported", reason: null }, } let capabilitiesPromise: Promise<BrowserCapabilities> | null = null @@ -57,6 +59,15 @@ export function browserCapabilitiesSnapshot(): BrowserCapabilities | null { return resolvedCapabilities } +/** + * Fresh answer, bypassing the session cache: the proxy part changes whenever + * the user edits the app's proxy setting, and the settings section shows it. + */ +export function browserCapabilitiesNow(): Promise<BrowserCapabilities> { + if (!isDesktop()) return Promise.resolve(UNAVAILABLE) + return getTransport().call<BrowserCapabilities>("browser_capabilities", {}) +} + /** Tests only. */ export function resetBrowserCapabilitiesCacheForTests(): void { capabilitiesPromise = null diff --git a/src/lib/browser/types.test.ts b/src/lib/browser/types.test.ts index 27d812a0b2..4e2e4c62a5 100644 --- a/src/lib/browser/types.test.ts +++ b/src/lib/browser/types.test.ts @@ -40,6 +40,12 @@ describe("browser wire types", () => { platform: "macos", channel: "degraded", reasons: ["page channel not installed yet"], + isolatedStorage: true, + proxy: { + url: "http://127.0.0.1:7890", + applies: "live", + reason: null, + }, } satisfies BrowserCapabilities const popup = { presentation: "adopted", diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index 2f3653e3b5..23315994ff 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -50,12 +50,31 @@ export interface BrowserTabState { openerTabId: string | null } +/** How the platform takes a change of the app's proxy setting. */ +export type BrowserProxyApplies = + | "live" + | "next-tab" + | "restart" + | "unsupported" + +export interface BrowserProxyStatus { + /** Proxy browser tabs use, as `scheme://host:port`; null = direct. */ + url: string | null + applies: BrowserProxyApplies + /** Why `url` is null although a proxy is configured, or why the shown proxy + * is not the configured one (Windows until a restart). */ + reason: string | null +} + export interface BrowserCapabilities { available: boolean surface: SurfaceKind | null platform: string channel: ChannelKind reasons: string[] + /** Browsing data lives apart from the app's own web storage. */ + isolatedStorage: boolean + proxy: BrowserProxyStatus } export type SurfaceChoice = "auto" | "child" | "window" From 067c2429506e46e2141e6e9e0756b54757355dfe Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Mon, 7 Sep 2026 23:58:48 +0800 Subject: [PATCH 16/79] fix(browser): show the error page when the requested page never arrives The load watcher only raised an error when nothing had ever committed; navigating an existing page to an address that fails just stopped the spinner and left the old page, with no hint of what happened. Any load that ends without the requested page now shows the error page for that address (a failed reload of the page already showing still just stops the spinner), reloads arm the watcher too, and retry re-navigates to the failed address instead of reloading the document underneath. --- src-tauri/src/browser/hooks.rs | 25 ++++++++++++--------- src-tauri/src/commands/browser.rs | 37 ++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index a77d3ac888..c7a888cc53 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -83,9 +83,9 @@ const LOAD_FINISH_GRACE: Duration = Duration::from_millis(300); /// wry reports navigation start and finish but never failure, so a DNS, /// connection or TLS error would leave `loading: true` forever. Poll the /// engine's own flag until it clears; if our state is still loading a moment -/// later, the load ended without a document — surface it as an error, or, -/// when an older document is still showing, just stop the spinner. A newer -/// navigation (higher `load_seq`) retires the watcher. +/// later, the load ended without the requested page — surface it as an error +/// (a failed reload of the page already showing just stops the spinner). A +/// newer navigation (higher `load_seq`) retires the watcher. fn watch_load(app: AppHandle, tab_id: String, seq: u64) { tauri::async_runtime::spawn(async move { loop { @@ -120,14 +120,19 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { let has_document = surface.url().is_ok(); let next = registry.update_state(&tab_id, |state| { state.loading = false; - if !has_document { - // Nothing committed, so `url` is still empty: the error - // page needs the address the user asked for. The wording - // is the status layer's, in the user's language. - let url = if state.url.is_empty() { - state.requested_url.clone() - } else { + // The load ended without the requested page: either nothing + // ever committed, or an older document is still showing while + // the address the user asked for never arrived (a reload that + // failed keeps its page and just stops the spinner). The error + // page names the requested address; its wording is the status + // layer's, in the user's language. + let requested_arrived = has_document + && (state.requested_url.is_empty() || state.requested_url == state.url); + if !requested_arrived { + let url = if state.requested_url.is_empty() { state.url.clone() + } else { + state.requested_url.clone() }; state.error = Some(BrowserErrorInfo { kind: BrowserErrorKind::Failed, diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 31a17436f7..eced85a51c 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -411,19 +411,36 @@ pub fn reload_core( tab_id: &str, ) -> Result<(), AppCommandError> { let surface = surface_of(registry, tab_id)?; - // A navigation that failed before committing left no document behind, and - // reloading nothing does nothing — the error page's retry button has to - // start the requested navigation over. - if surface.url().is_err() { - let requested = registry.state(tab_id).map(|s| s.requested_url); - if let Some(url) = requested.filter(|u| !u.is_empty()) { - navigate_core(app, registry, tab_id, &url)?; - return Ok(()); - } + let current = registry + .state(tab_id) + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; + // Retry rather than reload when the page showing is not the one asked + // for: a navigation that failed before committing left nothing to reload + // (or left an older document, which the error page now covers), and the + // error page's button means "try that address again". + let retry = current.error.is_some() || surface.url().is_err(); + if retry && !current.requested_url.is_empty() { + navigate_core(app, registry, tab_id, ¤t.requested_url)?; + return Ok(()); } + // A reload asks for the document that is showing; saying so keeps the + // load watcher's "did the requested page arrive" check honest after an + // in-page (pushState) navigation moved `url` away from the last request. + let state = registry.update_state(tab_id, |state| { + if !state.url.is_empty() { + state.requested_url = state.url.clone(); + } + state.loading = true; + state.error = None; + }); surface .reload() - .map_err(|e| window_err("Failed to reload browser tab", e)) + .map_err(|e| window_err("Failed to reload browser tab", e))?; + hooks::begin_load(app, tab_id); + if let Some(state) = state { + events::emit_state(app, &state); + } + Ok(()) } pub fn go_back_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { From d4a89a49c5dda45c939921267017ececa6894013 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 09:45:37 +0800 Subject: [PATCH 17/79] feat(browser): keep browser tabs across restarts and unload idle ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser tab is one URL, so unlike a file tab it is worth bringing back: the dev-server page next to the chat is exactly what a user looks for after relaunching. Records are restored "not loaded" — the native surface of a tab is created when it is first shown — so restoring twenty tabs costs twenty small records and no webviews. - `lib/browser/browser-tab-persistence.ts`: per-window-label localStorage list (url, title, folderId), versioned, sanitised on read so one corrupt entry cannot take the list with it. The stored address is the first web URL among the live one, the requested one and the record's. - `BrowserTabsPersistence`: restores once per document, then debounces writes behind the tab store. Nothing is written before the restore has run, or the empty strip of a fresh document would erase the previous run's tabs. - The surface ledger moves from the host component into the store, so a released tab is "not loaded" again and the same host resumes it. - Optional background unload (off by default): a tab off screen for 30 minutes has its surface released; its record keeps the page it was showing. The tab strip draws unloaded tabs faded. - ⇧⌘T reopens a closed browser tab at the page it was showing. --- src/app/workspace/layout.tsx | 4 + .../browser/browser-surface-host.tsx | 29 ++- .../browser/browser-tabs-persistence.test.tsx | 204 ++++++++++++++++ .../browser/browser-tabs-persistence.tsx | Bin 0 -> 4466 bytes .../browser/browser-tabs-suspender.test.tsx | 127 ++++++++++ .../browser/browser-tabs-suspender.tsx | 69 ++++++ .../files/file-workspace-tab-bar.tsx | 12 +- .../layout/workspace-chrome-controller.tsx | 18 +- .../settings/browser-settings.test.tsx | 14 ++ src/components/settings/browser-settings.tsx | 31 ++- src/contexts/workspace-context.test.tsx | 169 ++++++++++++- src/contexts/workspace-context.tsx | 120 ++++++++- src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- src/lib/browser/browser-prefs.ts | 12 + .../browser/browser-tab-persistence.test.ts | 227 ++++++++++++++++++ src/lib/browser/browser-tab-persistence.ts | 194 +++++++++++++++ src/lib/browser/browser-tab-store.test.ts | 35 +++ src/lib/browser/browser-tab-store.ts | 50 +++- src/lib/closed-tab-stack.test.ts | 19 ++ src/lib/closed-tab-stack.ts | 28 ++- 29 files changed, 1361 insertions(+), 41 deletions(-) create mode 100644 src/components/browser/browser-tabs-persistence.test.tsx create mode 100644 src/components/browser/browser-tabs-persistence.tsx create mode 100644 src/components/browser/browser-tabs-suspender.test.tsx create mode 100644 src/components/browser/browser-tabs-suspender.tsx create mode 100644 src/lib/browser/browser-tab-persistence.test.ts create mode 100644 src/lib/browser/browser-tab-persistence.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 48c98700e4..132735260e 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -1,6 +1,8 @@ "use client" import { BrowserEventsBridge } from "@/components/browser/browser-events-bridge" +import { BrowserTabsPersistence } from "@/components/browser/browser-tabs-persistence" +import { BrowserTabsSuspender } from "@/components/browser/browser-tabs-suspender" import { Suspense, useMemo, @@ -1281,6 +1283,8 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { <WorkspaceDocumentTitle /> <TabKeysSync /> <BrowserEventsBridge /> + <BrowserTabsPersistence /> + <BrowserTabsSuspender /> <HeavyPluginsWarmup /> <DeepLinkBootstrap /> <PetFocusBridge /> diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index 7d08e4d6c5..2ce7e5717b 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -13,7 +13,11 @@ import { } from "@/lib/browser/browser-api" import { getBrowserPrefs } from "@/lib/browser/browser-prefs" import { + claimSurfaceCreation, + forgetSurfaceCreation, getBrowserTabState, + markBrowserTabHidden, + markBrowserTabShown, setBrowserTabState, } from "@/lib/browser/browser-tab-store" import { @@ -29,11 +33,6 @@ import { cn } from "@/lib/utils" * `checkVisibility()` call per tick. */ const VISIBILITY_POLL_MS = 500 -// Backend ids whose surface this window has asked to create. Guards the -// StrictMode double-effect and re-mounts of the same tab: the webview lives -// as long as the tab record, not as long as this component. -const created = new Set<string>() - function measure(el: HTMLElement): Bounds { const rect = el.getBoundingClientRect() return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } @@ -123,17 +122,18 @@ export function BrowserSurfaceHost({ }, [backendId, shouldShow, tab.id]) // Create the surface once per tab record; adopted popups and re-mounts - // already have one (the store knows about it). + // already have one (the store knows about it). A record whose surface was + // released (background unload) is "not loaded" again and gets a new one + // here, at the URL the record was updated to. useEffect(() => { const el = ref.current if (!el || !backendId) return - if (created.has(backendId) || getBrowserTabState(tab.id)) { + if (getBrowserTabState(tab.id) || !claimSurfaceCreation(backendId)) { lastBoundsRef.current = null lastVisibleRef.current = null sync() return } - created.add(backendId) const bounds = measure(el) lastBoundsRef.current = bounds lastVisibleRef.current = true @@ -153,7 +153,7 @@ export function BrowserSurfaceHost({ sync() }) .catch((error: unknown) => { - created.delete(backendId) + forgetSurfaceCreation(backendId) setCreateError(String(error)) }) // Intentionally not re-run on `sync` identity changes: creation is a @@ -195,16 +195,19 @@ export function BrowserSurfaceHost({ sync() }, [sync, view.mode, view.activePane, view.filesMaximized, routeVisible]) - // Unmount: the tab is no longer on screen (another tab took the pane, the - // drawer closed, the panel went away). Hide, never destroy — the record - // owns the surface. + // Mount = the tab is on screen; unmount = it is no longer (another tab took + // the pane, the drawer closed, the panel went away). Hide, never destroy — + // the record owns the surface. The store keeps the timestamps so the + // optional background unload knows how long a page has been off screen. useEffect(() => { if (!backendId) return + markBrowserTabShown(tab.id) return () => { lastVisibleRef.current = null + markBrowserTabHidden(tab.id) void browserSetVisible(backendId, false, false).catch(() => {}) } - }, [backendId]) + }, [backendId, tab.id]) return ( <div diff --git a/src/components/browser/browser-tabs-persistence.test.tsx b/src/components/browser/browser-tabs-persistence.test.tsx new file mode 100644 index 0000000000..5c22eae431 --- /dev/null +++ b/src/components/browser/browser-tabs-persistence.test.tsx @@ -0,0 +1,204 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" +import type { BrowserCapabilities } from "@/lib/browser/types" + +const mocks = vi.hoisted(() => ({ + capabilities: vi.fn( + (): Promise<BrowserCapabilities> => + Promise.resolve({ + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live", reason: null }, + }) + ), + restoreBrowserTabs: vi.fn(), + fileTabs: [] as FileWorkspaceTab[], +})) + +vi.mock("@/lib/browser/browser-api", () => ({ + browserCapabilities: mocks.capabilities, +})) +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceActions: () => ({ restoreBrowserTabs: mocks.restoreBrowserTabs }), + useWorkspaceFileTabs: () => ({ fileTabs: mocks.fileTabs }), +})) + +import { + browserTabsStorageKey, + readPersistedBrowserTabs, + writePersistedBrowserTabs, +} from "@/lib/browser/browser-tab-persistence" +import { + resetBrowserTabStoreForTests, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { + BrowserTabsPersistence, + resetBrowserTabsPersistenceForTests, +} from "./browser-tabs-persistence" + +function browserTab(id: string, initialUrl: string): FileWorkspaceTab { + return { + id: `browser:${id}`, + kind: "browser", + folderId: 1, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: false, + readonly: true, + browser: { initialUrl, openerTabId: null }, + } as FileWorkspaceTab +} + +async function flush() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe("BrowserTabsPersistence", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + localStorage.clear() + mocks.restoreBrowserTabs.mockClear() + mocks.capabilities.mockClear() + mocks.fileTabs = [] + resetBrowserTabsPersistenceForTests() + resetBrowserTabStoreForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetBrowserTabsPersistenceForTests() + resetBrowserTabStoreForTests() + }) + + it("restores the stored tabs once, then persists what the strip shows", async () => { + writePersistedBrowserTabs( + [{ url: "https://example.com/a", title: "A", folderId: 2 }], + "main" + ) + const { rerender } = render(<BrowserTabsPersistence />) + await flush() + expect(mocks.restoreBrowserTabs).toHaveBeenCalledWith([ + { url: "https://example.com/a", title: "A", folderId: 2 }, + ]) + + // The provider adds the record; the page then loads and reports a deeper + // URL and a real title through the store. + mocks.fileTabs = [browserTab("t1", "https://example.com/a")] + setBrowserTabState({ + tabId: "t1", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/a/deep", + requestedUrl: "https://example.com/a/deep", + title: "Deep", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + }) + rerender(<BrowserTabsPersistence />) + await act(async () => { + vi.advanceTimersByTime(500) + }) + expect(readPersistedBrowserTabs("main")).toEqual([ + { url: "https://example.com/a/deep", title: "Deep", folderId: 1 }, + ]) + }) + + // The failure this guards: a fresh document mounts with an empty strip, and + // a write before the restore would erase the previous run's tabs. + it("does not write before the restore has run", async () => { + writePersistedBrowserTabs( + [{ url: "https://example.com/a", title: "A", folderId: null }], + "main" + ) + let resolveCaps: (caps: BrowserCapabilities) => void = () => {} + mocks.capabilities.mockImplementationOnce( + () => + new Promise<BrowserCapabilities>((resolve) => { + resolveCaps = resolve + }) + ) + render(<BrowserTabsPersistence />) + await act(async () => { + vi.advanceTimersByTime(2000) + }) + expect(readPersistedBrowserTabs("main")).toEqual([ + { url: "https://example.com/a", title: "A", folderId: null }, + ]) + + await act(async () => { + resolveCaps({ + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live", reason: null }, + }) + await Promise.resolve() + }) + expect(mocks.restoreBrowserTabs).toHaveBeenCalledTimes(1) + }) + + it("clears the stored list once the last browser tab is closed", async () => { + writePersistedBrowserTabs( + [{ url: "https://example.com/a", title: "A", folderId: null }], + "main" + ) + mocks.fileTabs = [browserTab("t1", "https://example.com/a")] + const { rerender } = render(<BrowserTabsPersistence />) + await flush() + + mocks.fileTabs = [] + rerender(<BrowserTabsPersistence />) + await act(async () => { + vi.advanceTimersByTime(500) + }) + expect(localStorage.getItem(browserTabsStorageKey("main"))).toBeNull() + }) + + it("stays out of the way in web mode", async () => { + writePersistedBrowserTabs( + [{ url: "https://example.com/a", title: "A", folderId: null }], + "main" + ) + mocks.capabilities.mockResolvedValueOnce({ + available: false, + surface: null, + platform: "web", + channel: "degraded", + reasons: [], + isolatedStorage: false, + proxy: { url: null, applies: "unsupported", reason: null }, + }) + render(<BrowserTabsPersistence />) + await act(async () => { + vi.advanceTimersByTime(2000) + }) + expect(mocks.restoreBrowserTabs).not.toHaveBeenCalled() + // The desktop run's tabs are still there for the next desktop run. + expect(readPersistedBrowserTabs("main")).toEqual([ + { url: "https://example.com/a", title: "A", folderId: null }, + ]) + }) +}) diff --git a/src/components/browser/browser-tabs-persistence.tsx b/src/components/browser/browser-tabs-persistence.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5d54c3872f112ff4f6f78e7cc2d1654a0bd7df95 GIT binary patch literal 4466 zcma)A+in{-5N%)m6%(NdR)#Kl=u;9INnNK8wVT3Dnuj1rs9j28&0Q`?t`);E(2wXB z_Dgz(7ilHSKn%o^$>DJ3%*>f#2Az{sY9$*#I66{wYpka`!s^*#Axl5vuNSg5;mf65 zgwHE4yrg?tSX0x$N>TPt9}yq=VC=PPMJZpGUYW*?_Lko$E#HYb*0Y{h)6z6ve)Vp; z&WFcoX|T0zZq3?Bdn#HnS6X=`-NuwxQ?2IHB#}SIqE*}TFarixe@g3=^RoISs*9!& zIZ?|;_Zrc<mGOH=)>ipP&-mL`(gB5iw-4Fu#eCc*8=F$@*uRuL6GAK)T-VKAY1Lfr zn70M@v0I!swx^$<{j{?d8Sz0im06#Qxzvv!ybcm$%_7L3>G3h08lj~t5fnl5&N@$K zLFGy`ODq+FTCtR*ji{(LH&}Xqc}}9K$SbcUsfPU89MLh6ynzXBh$U~xG<>KO`U=*v zh5-76j;BY+5L|Ng`meLkzn=Z__Wd8HXP+;wXhuIjdj^xH6y2a;q$9sljw(}jHPn;P zoD8=}i2y@I3u7nrTPJMAqLQw>Ri(c$m1I?w>E^IfhS~^b3mhHSrh_i{$tO0sORY6G zszMNHt#EC_UPLYN_7{Mtk?XBBg_VrV2s*XsnsPNH=Y{o-WZnAPaAC64O_Jl;^5~_5 z->mXV`j-x-H_MY^7z6VTE;^W?Zv$-dYpC@FuHpJ*1o~;^a?T?8-WVGW7sKatV^l?V zp@<@4>E21HrLef`KHSC;{9Vd6f|v@Dkkqh%VL}XI?r_9+0?WBDXT!tMn&zUsW^FK$ z(QUvFWV>cU7Yw?ruu*c}I3WEP{-bydc?1=#Ean#9Q!V8S_}+vtHHO2qoIr%7-de0u zExhL#3Lk^fwa+OD61#!w_p%`vqjWGJMTAzuv8AX@SZ~8t3?m1oWgG+;RJzqR0oJC| z6_5dEtXH^@J=Oy04LzWy8(Fb!^cF#cfV46cnbLj?7&FT9D~Qd}fB*c;;I8Eyl@_sK zdHickmStrjD4_yzS*ijJ4O9er7gR}i?M)kO5K955qhU;ya7-;#^s#HSx|XnP-g#<` za|*SB`gNq`f=dnQ&*o2$_H(btSiQ6>h9S{necy3{GE?<rK}xejtp-_0l#eY^pLrmh z8&FUBV<3YOp%N~c7CuG&fntk=&`z>u@W46xpYvlBC;DKmtVMjm93fs_)toM1M6;&C z-6B0JhQp(m)wM!PWtdnr>Uzi*$OjN3>r-5@>xR%^xkkZ%n(M(0fn)M8`-k2ft;Lrj zFS5CtUS0su*-5~ZC`&+kQbxMR@w|#-_apk4|NOKCl(X?eO!@weFLT^_{q*A}`bJHs z^-00MK|=w%%NWO4w`g&j>>n{EAjuZLIPsSZE~5~*9)$OpFIsv)1Dv%6)bN5M1!;<G zmFl8DFciZH-&GVPI(iQkBDEkaW?kRD(GwP#h#R5MKscYR^qp;j`W%DMT5$2f)*14P zjbYVKQWjU4HbD|aI6jQo&VoFl?2@afsw9=}%{QZmSss99IC6NX**~`%z+UW=s?7y# zZd#jY*)08vPUso?zh8%0jI)G)?$c*3qr%%RR1UVHOvI4-2?>uju+NB31TCzKQ}N-g zNJ{K+f6q)Ac~u*84a(sf0|76D<qzap823W2a9ld2w2iEuZ<&x)Sy>u%{*Z~vg51ym zMsYkrLSiyOg-DIjfZINca)nBb0Y_Ml!-U>|ZlYP>Rm?p|6)LXfEo?K_5}IR+N798N zM<YqY5mFvZ8GAMu$Ut)tiON{cl`C0w7(Yasey|l;&Z%W1kd+d7kmmIQn-l>K-?kN> z{v?{9ywTZ;0+xmvz~|%|6CKTFGa96_8RV$qKrPy>^p~IR_<*YJe~91Gfr)A`lnedd z>bw(XyZbLaO@ABJ6oVfJL!Mlz$29me#Pu+1r4j-)@6;g^K%iZ8=B}QDQzG#I4rZQk zMM4Ta6svoQ7bZxTrh9DXuf1Lq@nl;wDfXB%I>DO{NAm=C_MJUQOB7oBmP|Lb`1I*U z;<4uls=Cj^`6v=6d6DniWtARt<sv<-s0Tu_C6Mp|mUqSOl~Hd(J7?@Z;*3HF7(GF% zh3<gD=vfyxXK6^BfJxq=t(wm7G%-dzOKt+qi##fAco+1s|2l~1^i5sp4ujbD*vZ3h z{D~86oP4u+!JG{KLF+Q`JsaavuJ2?xj~NQt$`7PzdLHF>6tfMs{K}ecxeDEq=_U{} zhFy>aH_*gp+&D8+bj{|O$RR%PWGu27(TU_E8qgWP$Iki9+<-I&{Ek~;<{peFU>C&? z5_|8`O;D0vi~m}<k>8~{aQ$mAc~fCVj}%CChhdcco%8#U2U8QcdCUWIvE{`G+z^bO zq6c&Z5AEUU_9DTxaI`WQz!^LpMm$W=ZO88~Jm5epYjGRV1PyS)BzHJ|u}OiMP=lF{ NV~a|dgao}m`XA<$x|#q0 literal 0 HcmV?d00001 diff --git a/src/components/browser/browser-tabs-suspender.test.tsx b/src/components/browser/browser-tabs-suspender.test.tsx new file mode 100644 index 0000000000..85589c190a --- /dev/null +++ b/src/components/browser/browser-tabs-suspender.test.tsx @@ -0,0 +1,127 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" + +const mocks = vi.hoisted(() => ({ + suspendBrowserTab: vi.fn(), + fileTabs: [] as FileWorkspaceTab[], + isDesktop: vi.fn(() => true), +})) + +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceActions: () => ({ suspendBrowserTab: mocks.suspendBrowserTab }), + useWorkspaceFileTabs: () => ({ fileTabs: mocks.fileTabs }), +})) +vi.mock("@/lib/transport", () => ({ isDesktop: mocks.isDesktop })) + +import { + resetBrowserPrefsForTests, + setBrowserSuspendBackgroundTabs, +} from "@/lib/browser/browser-prefs" +import { + markBrowserTabHidden, + markBrowserTabShown, + resetBrowserTabStoreForTests, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { BROWSER_TAB_SUSPEND_AFTER_MS } from "@/lib/browser/browser-tab-persistence" +import { BrowserTabsSuspender, SUSPEND_POLL_MS } from "./browser-tabs-suspender" + +function browserTab(id: string): FileWorkspaceTab { + return { + id: `browser:${id}`, + kind: "browser", + folderId: 1, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: false, + readonly: true, + browser: { initialUrl: "https://example.com/", openerTabId: null }, + } as FileWorkspaceTab +} + +function loaded(id: string) { + setBrowserTabState({ + tabId: id, + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/", + requestedUrl: "https://example.com/", + title: "Example", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + }) +} + +describe("BrowserTabsSuspender", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mocks.suspendBrowserTab.mockClear() + mocks.isDesktop.mockReturnValue(true) + mocks.fileTabs = [] + resetBrowserPrefsForTests() + resetBrowserTabStoreForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetBrowserPrefsForTests() + resetBrowserTabStoreForTests() + }) + + it("does nothing while the preference is off", async () => { + mocks.fileTabs = [browserTab("t1")] + loaded("t1") + markBrowserTabHidden("browser:t1") + render(<BrowserTabsSuspender />) + await act(async () => { + vi.advanceTimersByTime(BROWSER_TAB_SUSPEND_AFTER_MS + SUSPEND_POLL_MS * 2) + }) + expect(mocks.suspendBrowserTab).not.toHaveBeenCalled() + }) + + it("releases a loaded tab once it has been off screen long enough", async () => { + setBrowserSuspendBackgroundTabs(true) + mocks.fileTabs = [browserTab("idle"), browserTab("onscreen")] + loaded("idle") + loaded("onscreen") + markBrowserTabHidden("browser:idle") + markBrowserTabShown("browser:onscreen") + + render(<BrowserTabsSuspender />) + await act(async () => { + vi.advanceTimersByTime(SUSPEND_POLL_MS) + }) + expect(mocks.suspendBrowserTab).not.toHaveBeenCalled() + + await act(async () => { + vi.advanceTimersByTime(BROWSER_TAB_SUSPEND_AFTER_MS) + }) + expect(mocks.suspendBrowserTab).toHaveBeenCalledWith("browser:idle") + expect(mocks.suspendBrowserTab).not.toHaveBeenCalledWith("browser:onscreen") + }) + + it("stops polling in web mode", async () => { + setBrowserSuspendBackgroundTabs(true) + mocks.isDesktop.mockReturnValue(false) + mocks.fileTabs = [browserTab("t1")] + loaded("t1") + markBrowserTabHidden("browser:t1") + render(<BrowserTabsSuspender />) + await act(async () => { + vi.advanceTimersByTime(BROWSER_TAB_SUSPEND_AFTER_MS + SUSPEND_POLL_MS * 2) + }) + expect(mocks.suspendBrowserTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/browser/browser-tabs-suspender.tsx b/src/components/browser/browser-tabs-suspender.tsx new file mode 100644 index 0000000000..833aaac787 --- /dev/null +++ b/src/components/browser/browser-tabs-suspender.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useEffect, useRef } from "react" + +import { + useWorkspaceActions, + useWorkspaceFileTabs, +} from "@/contexts/workspace-context" +import { useBrowserPrefs } from "@/lib/browser/browser-prefs" +import { + BROWSER_TAB_SUSPEND_AFTER_MS, + selectBrowserTabsToSuspend, +} from "@/lib/browser/browser-tab-persistence" +import { + browserTabHiddenAt, + getBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { isDesktop } from "@/lib/transport" + +/** How often the background tabs are checked against the idle threshold; a + * page is never released to the minute, so a coarse tick is enough. */ +export const SUSPEND_POLL_MS = 60 * 1000 + +/** + * The optional background unload (settings → built-in browser): a browser + * tab that has been off screen for `BROWSER_TAB_SUSPEND_AFTER_MS` has its + * native surface released; the record stays, on the page the tab was + * showing, and loads again when the tab is next switched to. Off by default. + * Renders nothing; mounted next to the events bridge. + */ +export function BrowserTabsSuspender() { + const { suspendBackgroundTabs } = useBrowserPrefs() + const { suspendBrowserTab } = useWorkspaceActions() + const { fileTabs } = useWorkspaceFileTabs() + // Latest-state mirror for the timers below (synced post-commit, like the + // workspace provider does for its own action callbacks). + const fileTabsRef = useRef(fileTabs) + useEffect(() => { + fileTabsRef.current = fileTabs + }, [fileTabs]) + + useEffect(() => { + if (!suspendBackgroundTabs || !isDesktop()) return + const tick = () => { + const candidates = fileTabsRef.current.flatMap((tab) => + tab.kind === "browser" + ? [ + { + id: tab.id, + hiddenAt: browserTabHiddenAt(tab.id), + loaded: getBrowserTabState(tab.id) !== null, + }, + ] + : [] + ) + for (const id of selectBrowserTabsToSuspend( + candidates, + Date.now(), + BROWSER_TAB_SUSPEND_AFTER_MS + )) { + suspendBrowserTab(id) + } + } + const timer = window.setInterval(tick, SUSPEND_POLL_MS) + return () => window.clearInterval(timer) + }, [suspendBackgroundTabs, suspendBrowserTab]) + + return null +} diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 40177321bb..44d64a178a 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -232,6 +232,10 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ // A browser tab's title follows the page (document.title); the record only // knows the host it was opened with. const browserState = useBrowserTabState(isBrowser ? tab.id : null) + // No live state = no page behind the tab yet: restored from a previous run + // or unloaded in the background; it loads when switched to. Drawn faded, + // the way browsers draw a discarded tab. + const unloaded = isBrowser && !browserState const displayTitle = isBrowser ? browserState?.title || tab.title : tab.title const displayHint = isBrowser ? browserState?.url || tab.browser.initialUrl @@ -336,7 +340,10 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ title={displayHint} > {isBrowser ? ( - <Globe className="h-3.5 w-3.5" /> + <Globe + className={cn("h-3.5 w-3.5", unloaded && "opacity-50")} + data-unloaded={unloaded ? "true" : undefined} + /> ) : isDiff ? ( <GitCompare className="h-3.5 w-3.5" /> ) : ( @@ -350,7 +357,8 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ // the full width is used. Standalone: ellipsis cap in the scroll row. embedded ? "min-w-0 flex-1 overflow-hidden whitespace-nowrap browser-tab-label" - : "truncate max-w-[11.25rem]" + : "truncate max-w-[11.25rem]", + unloaded && "opacity-60" )} > {displayTitle} diff --git a/src/components/layout/workspace-chrome-controller.tsx b/src/components/layout/workspace-chrome-controller.tsx index c207a241d3..658e46b05d 100644 --- a/src/components/layout/workspace-chrome-controller.tsx +++ b/src/components/layout/workspace-chrome-controller.tsx @@ -52,8 +52,13 @@ export function WorkspaceChromeController() { // owns them too (see the keydown handler below). const { mode, activePane, filesMaximized } = useWorkspaceView() const { activeFileTabId, fileTabs } = useWorkspaceFileTabs() - const { closeFileTab, closeAllFileTabs, switchFileTab, openFilePreview } = - useWorkspaceActions() + const { + closeFileTab, + closeAllFileTabs, + switchFileTab, + openFilePreview, + openBrowserTab, + } = useWorkspaceActions() const { openConversations } = useWorkbenchRoute() const { shortcuts } = useShortcutSettings() // Search open-state is shared (see search-dialog-context): the trigger lives @@ -214,6 +219,14 @@ export function WorkspaceChromeController() { }) return } + if (closed.kind === "browser") { + // Back at the page it was showing; a tab already on that page is + // activated instead (the usual one-tab-per-URL rule). + openBrowserTab(closed.url, { + folderId: closed.folderId ?? undefined, + }) + return + } if (closed.conversationId != null) { // A deletion seen at any point wins. `applyConversationRemove` // purges what is on the stack when it runs, but a tab that was @@ -251,6 +264,7 @@ export function WorkspaceChromeController() { openNewConversationTab, openTab, openFilePreview, + openBrowserTab, setSearchOpen, shortcuts, toggle, diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index a1b5ee8226..a79c1254d8 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -88,6 +88,20 @@ describe("BrowserSettingsSection", () => { ).toHaveTextContent("Automatic") }) + it("persists the background-unload switch, which is off by default", () => { + renderSection() + expandSection() + const toggle = screen.getByLabelText("Unload background tabs") + expect(toggle).not.toBeChecked() + + fireEvent.click(toggle) + expect(getBrowserPrefs().suspendBackgroundTabs).toBe(true) + expect(screen.getByLabelText("Unload background tabs")).toBeChecked() + + fireEvent.click(screen.getByLabelText("Unload background tabs")) + expect(getBrowserPrefs().suspendBackgroundTabs).toBe(false) + }) + it("persists the inspector switch and follows a change made elsewhere", () => { renderSection() expandSection() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index d5640b8773..7332d6db9d 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -2,8 +2,9 @@ /** * Built-in browser settings: where links open by default (per source), whether - * browser tabs get the web inspector, which native surface hosts them, and a - * one-shot "clear browsing data". + * browser tabs get the web inspector, which native surface hosts them, whether + * background tabs are unloaded after a while, and a one-shot "clear browsing + * data". * * Preferences live in localStorage (`browser-prefs.ts`): written immediately, * mirrored across windows through the storage event, so there is no Save @@ -14,7 +15,15 @@ import { useEffect, useState } from "react" import { useTranslations } from "next-intl" -import { AppWindow, Eraser, Globe, Link2, Network, Wrench } from "lucide-react" +import { + AppWindow, + Eraser, + Globe, + Link2, + MoonStar, + Network, + Wrench, +} from "lucide-react" import { toast } from "sonner" import { SettingCard, SettingRow } from "@/components/shared/setting-card" @@ -47,6 +56,7 @@ import { LINK_SOURCES, setBrowserDevtools, setBrowserSurfaceOverride, + setBrowserSuspendBackgroundTabs, setDefaultLinkTarget, useBrowserPrefs, type LinkSource, @@ -238,6 +248,21 @@ export function BrowserSettingsSection() { </Select> } /> + <SettingRow + icon={MoonStar} + title={t("suspendTitle")} + description={t("suspendHint")} + htmlFor="browser-suspend" + control={ + <Switch + id="browser-suspend" + checked={prefs.suspendBackgroundTabs} + onCheckedChange={(enabled) => + setBrowserSuspendBackgroundTabs(enabled) + } + /> + } + /> <SettingRow icon={Network} title={t("proxyTitle")} diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx index dd74ec78ba..0cf6c185f7 100644 --- a/src/contexts/workspace-context.test.tsx +++ b/src/contexts/workspace-context.test.tsx @@ -9,6 +9,15 @@ import { useWorkspaceView, } from "@/contexts/workspace-context" import * as api from "@/lib/api" +import { + getBrowserTabState, + resetBrowserTabStoreForTests, + setBrowserTabState, +} from "@/lib/browser/browser-tab-store" +import { + popClosedTab, + resetClosedTabStackForTests, +} from "@/lib/closed-tab-stack" import { resetHomeDirCacheForTests } from "@/lib/file-open-target" import { resetAppWorkspaceStore, @@ -3254,9 +3263,19 @@ describe("unified absolute-path file tabs (outside-workspace opens)", () => { }) describe("browser tabs", () => { + beforeEach(() => { + resetBrowserTabStoreForTests() + resetClosedTabStackForTests() + }) + function BrowserProbe() { - const { openBrowserTab, adoptBrowserTab, closeFileTab } = - useWorkspaceActions() + const { + openBrowserTab, + adoptBrowserTab, + closeFileTab, + restoreBrowserTabs, + suspendBrowserTab, + } = useWorkspaceActions() const { fileTabs, activeFileTabId } = useWorkspaceFileTabs() const { activePane } = useWorkspaceView() return ( @@ -3314,6 +3333,33 @@ describe("browser tabs", () => { > close-active </button> + <button + onClick={() => + restoreBrowserTabs([ + { + url: "https://restored.example/one", + title: "One", + folderId: 7, + }, + { url: "not a url", title: "bad", folderId: null }, + { + url: "https://restored.example/two", + title: "", + folderId: null, + }, + ]) + } + > + restore + </button> + <button + onClick={() => { + const tab = fileTabs.find((t) => t.kind === "browser") + if (tab) suspendBrowserTab(tab.id) + }} + > + suspend-first + </button> <pre data-testid="tabs"> {JSON.stringify( fileTabs.map((t) => ({ @@ -3430,4 +3476,123 @@ describe("browser tabs", () => { expect(screen.getByTestId("active").textContent).toBe(tabs[0].id) confirmSpy.mockRestore() }) + + // Restored records carry their stored title and folder, are appended in + // order, and none is activated: the surface of a restored tab is created + // when it is first shown, not at startup. + it("restores stored tabs as inactive records, skipping unusable addresses", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("restore").click()) + const tabs = readTabs() + expect(tabs.map((t) => t.url)).toEqual([ + "https://restored.example/one", + "https://restored.example/two", + ]) + expect(tabs[0].title).toBe("One") + // No stored title: the host, as for any freshly opened tab. + expect(tabs[1].title).toBe("restored.example") + expect(screen.getByTestId("active").textContent).toBe("") + expect(screen.getByTestId("pane").textContent).toBe("conversation") + + // A second restore (another document of the same run) is a no-op. + act(() => screen.getByText("restore").click()) + expect(readTabs()).toHaveLength(2) + }) + + it("does not restore over tabs this window already has", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + act(() => screen.getByText("restore").click()) + expect(readTabs()).toHaveLength(1) + }) + + it("suspending a loaded tab keeps the record at the page it was showing", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + const opened = readTabs()[0] + const backendId = opened.id.slice("browser:".length) + act(() => + setBrowserTabState({ + tabId: backendId, + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/docs/deep", + requestedUrl: "https://example.com/docs/deep", + title: "Deep", + favicon: null, + loading: false, + canGoBack: true, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + }) + ) + act(() => screen.getByText("suspend-first").click()) + const tabs = readTabs() + expect(tabs).toHaveLength(1) + expect(tabs[0].id).toBe(opened.id) + expect(tabs[0].url).toBe("https://example.com/docs/deep") + expect(tabs[0].title).toBe("Deep") + // The native surface is gone; the record is "not loaded" again. + expect(getBrowserTabState(opened.id)).toBeNull() + + // Nothing to release the second time. + act(() => screen.getByText("suspend-first").click()) + expect(readTabs()).toHaveLength(1) + }) + + it("records a closed browser tab so it can be reopened at its page", () => { + render( + <WorkspaceProvider> + <BrowserProbe /> + </WorkspaceProvider> + ) + act(() => screen.getByText("open").click()) + const opened = readTabs()[0] + act(() => + setBrowserTabState({ + tabId: opened.id.slice("browser:".length), + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/docs/deep", + requestedUrl: "https://example.com/docs/deep", + title: "Deep", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + }) + ) + act(() => screen.getByText("close-active").click()) + const closed = popClosedTab() + expect(closed).toEqual({ + kind: "browser", + key: opened.id, + url: "https://example.com/docs/deep", + title: "Deep", + folderId: 1, + }) + }) }) diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index b80407d530..f5676d0640 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -36,7 +36,11 @@ import { splitAbsPath, } from "@/lib/file-open-target" import { isAbsoluteFilePath } from "@/lib/file-path-display" -import { pushClosedTab, snapshotFileTab } from "@/lib/closed-tab-stack" +import { + pushClosedTab, + snapshotBrowserTab, + snapshotFileTab, +} from "@/lib/closed-tab-stack" import { isBinaryImageFile, isHiddenPath, @@ -62,10 +66,24 @@ import { type WorkspaceExternalConflict, } from "@/hooks/use-open-file-tabs-watch" import { useOfficeAutoPreview } from "@/lib/office-preview-prefs" -import { releaseBrowserTab } from "@/lib/browser/browser-tab-store" +import { + getBrowserTabState, + releaseBrowserTab, +} from "@/lib/browser/browser-tab-store" import { hostnameOf, normalizeUrlForDedupe } from "@/lib/browser/browser-url" export type WorkspaceMode = "conversation" | "fusion" + +/** The closed-stack entry for a browser tab: the page it was showing, read + * from the live state while that still exists (it is released right after). */ +function closedBrowserTab(tab: BrowserWorkspaceTab) { + const state = getBrowserTabState(tab.id) + return snapshotBrowserTab( + tab, + state?.url || state?.requestedUrl || tab.browser.initialUrl, + state?.title || tab.title + ) +} export type WorkspacePane = "conversation" | "files" type FileLikeTabKind = "file" | "diff" | "rich-diff" @@ -247,6 +265,24 @@ interface WorkspaceActionsValue { url: string openerBackendTabId: string }) => string + // Bring back browser tabs saved by a previous run, as records only: none + // is activated, and a native surface is created for one when it is first + // shown. Entries are appended in order; nothing happens when the workspace + // already has browser tabs (a second document of the same run). + restoreBrowserTabs: (entries: RestorableBrowserTab[]) => void + // Release a background tab's native surface (memory) while keeping its + // record: the record moves to the page the tab was showing, so the next + // time it is shown a fresh surface loads that page. No-op for a tab that + // has no surface. Returns whether a surface was released. + suspendBrowserTab: (tabId: string) => boolean +} + +/** What a browser tab needs to come back after a restart (see + * `lib/browser/browser-tab-persistence`). */ +export interface RestorableBrowserTab { + url: string + title: string | null + folderId: number | null } interface WorkspaceViewValue { @@ -676,12 +712,13 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { backendTabId: string, url: string, folderId: number | null, - openerTabId: string | null + openerTabId: string | null, + title?: string | null ): BrowserWorkspaceTab => ({ id: buildFileTabId({ kind: "browser", id: backendTabId }), kind: "browser", folderId, - title: hostnameOf(url) ?? url, + title: title || (hostnameOf(url) ?? url), description: null, path: null, language: "browser", @@ -777,6 +814,62 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { [activateFilePane, browserTabRecord] ) + const restoreBrowserTabs = useCallback( + (entries: RestorableBrowserTab[]) => { + if (entries.length === 0) return + if (fileTabsRef.current.some((tab) => tab.kind === "browser")) return + const records = entries.flatMap((entry) => + normalizeUrlForDedupe(entry.url) + ? [ + browserTabRecord( + crypto.randomUUID(), + entry.url, + entry.folderId, + null, + entry.title + ), + ] + : [] + ) + if (records.length === 0) return + // Records only; not activated, so no surface is created until the user + // switches to one. The pane state is left exactly as it was. + setFileTabs((prev) => + prev.some((tab) => tab.kind === "browser") + ? prev + : [...prev, ...records] + ) + }, + [browserTabRecord] + ) + + const suspendBrowserTab = useCallback((tabId: string) => { + const tab = fileTabsRef.current.find((t) => t.id === tabId) + if (!tab || tab.kind !== "browser") return false + const state = getBrowserTabState(tabId) + if (!state) return false + const url = state.url || state.requestedUrl || tab.browser.initialUrl + const title = state.title || tab.title + // Move the record to where the page got to before letting the surface + // go: the tab strip keeps showing the page's title, and the surface + // created when the tab is next shown loads that page, not the address + // the tab was opened with. History and scroll position are lost, as in + // a browser's discarded tab. + setFileTabs((prev) => + prev.map((t) => + t.id === tabId && t.kind === "browser" + ? { + ...t, + title, + browser: { ...t.browser, initialUrl: url }, + } + : t + ) + ) + releaseBrowserTab(tabId) + return true + }, []) + // Mark an existing tab as refreshing. Preserves content / originalContent / // modifiedContent / gitBaseContent / savedContent / etag / mtimeMs / // isDirty / readonly / lineEnding. Clears any prior error state. @@ -2449,7 +2542,10 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) // Idempotent on the backend, so safe under a replayed updater. - if (tab.kind === "browser") releaseBrowserTab(tab.id) + if (tab.kind === "browser") { + pushClosedTab(closedBrowserTab(tab)) + releaseBrowserTab(tab.id) + } const next = prev.filter((candidate) => candidate.id !== tabId) @@ -2502,7 +2598,10 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { // safe inside an updater React may invoke more than once. const closed = snapshotFileTab(closing) if (closed) pushClosedTab(closed) - if (closing.kind === "browser") releaseBrowserTab(closing.id) + if (closing.kind === "browser") { + pushClosedTab(closedBrowserTab(closing)) + releaseBrowserTab(closing.id) + } inFlightLoadsRef.current.delete(closing.id) } @@ -2524,7 +2623,10 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { for (const tab of prev) { const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) - if (tab.kind === "browser") releaseBrowserTab(tab.id) + if (tab.kind === "browser") { + pushClosedTab(closedBrowserTab(tab)) + releaseBrowserTab(tab.id) + } } inFlightLoadsRef.current.clear() @@ -2698,6 +2800,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { toggleFilesMaximized, openBrowserTab, adoptBrowserTab, + restoreBrowserTabs, + suspendBrowserTab, }), [ setActivePane, @@ -2728,6 +2832,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { toggleFilesMaximized, openBrowserTab, adoptBrowserTab, + restoreBrowserTabs, + suspendBrowserTab, ] ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index fd68874c3d..d66479e617 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "يسري تغيير الوكيل على علامات التبويب التي تُفتح من الآن فصاعدًا.", "proxyRestart": "أعد تشغيل codeg لتستخدم علامات تبويب المتصفح الوكيل المتغيّر.", "proxyUnsupported": "غير متاح في هذا الإصدار من macOS (يلزم الإصدار 14 أو أحدث).", - "proxyUnusable": "الوكيل المُعدّ ليس من نوع يمكن لعلامات تبويب المتصفح استخدامه (http وsocks5 فقط)." + "proxyUnusable": "الوكيل المُعدّ ليس من نوع يمكن لعلامات تبويب المتصفح استخدامه (http وsocks5 فقط).", + "suspendTitle": "إلغاء تحميل علامات التبويب في الخلفية", + "suspendHint": "يحرّر ذاكرة علامات تبويب المتصفح التي تبقى في الخلفية لمدة 30 دقيقة. يُعاد تحميلها عند العودة إليها؛ ويُفقد موضع التمرير والسجل." }, "LogsSettings": { "loading": "جارٍ التحميل…", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 10c5c96f6d..e14b7f01bb 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "Ein geänderter Proxy gilt für ab jetzt geöffnete Tabs.", "proxyRestart": "Starte codeg neu, damit Browser-Tabs den geänderten Proxy verwenden.", "proxyUnsupported": "Auf dieser macOS-Version nicht verfügbar (14 oder neuer erforderlich).", - "proxyUnusable": "Der konfigurierte Proxy ist kein Typ, den Browser-Tabs verwenden können (nur http und socks5)." + "proxyUnusable": "Der konfigurierte Proxy ist kein Typ, den Browser-Tabs verwenden können (nur http und socks5).", + "suspendTitle": "Hintergrund-Tabs entladen", + "suspendHint": "Gibt den Speicher von Browser-Tabs frei, die 30 Minuten im Hintergrund waren. Beim Zurückwechseln werden sie neu geladen; Scrollposition und Verlauf gehen verloren." }, "LogsSettings": { "loading": "Wird geladen…", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 44fdc97e9f..c7dec2e118 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "A changed proxy applies to tabs opened from now on.", "proxyRestart": "Restart codeg for browser tabs to use the changed proxy.", "proxyUnsupported": "Not available on this version of macOS (14 or later is needed).", - "proxyUnusable": "The configured proxy is not one browser tabs can use (http and socks5 only)." + "proxyUnusable": "The configured proxy is not one browser tabs can use (http and socks5 only).", + "suspendTitle": "Unload background tabs", + "suspendHint": "Release the memory of browser tabs that stay in the background for 30 minutes. They load again when you switch back; scroll position and history are lost." }, "LogsSettings": { "loading": "Loading…", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 915076b070..91fabeeb2d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "Un cambio de proxy se aplica a las pestañas que se abran a partir de ahora.", "proxyRestart": "Reinicia codeg para que las pestañas del navegador usen el proxy cambiado.", "proxyUnsupported": "No disponible en esta versión de macOS (se necesita 14 o posterior).", - "proxyUnusable": "El proxy configurado no es de un tipo que las pestañas del navegador puedan usar (solo http y socks5)." + "proxyUnusable": "El proxy configurado no es de un tipo que las pestañas del navegador puedan usar (solo http y socks5).", + "suspendTitle": "Descargar pestañas en segundo plano", + "suspendHint": "Libera la memoria de las pestañas del navegador que llevan 30 minutos en segundo plano. Se vuelven a cargar al regresar; se pierden la posición de desplazamiento y el historial." }, "LogsSettings": { "loading": "Cargando…", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 088dc7ee51..53dafa54b1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "Un changement de proxy s’applique aux onglets ouverts à partir de maintenant.", "proxyRestart": "Redémarrez codeg pour que les onglets du navigateur utilisent le proxy modifié.", "proxyUnsupported": "Indisponible sur cette version de macOS (14 ou ultérieure requise).", - "proxyUnusable": "Le proxy configuré n’est pas d’un type utilisable par les onglets du navigateur (http et socks5 uniquement)." + "proxyUnusable": "Le proxy configuré n’est pas d’un type utilisable par les onglets du navigateur (http et socks5 uniquement).", + "suspendTitle": "Décharger les onglets en arrière-plan", + "suspendHint": "Libère la mémoire des onglets du navigateur restés 30 minutes en arrière-plan. Ils se rechargent quand vous y revenez ; la position de défilement et l’historique sont perdus." }, "LogsSettings": { "loading": "Chargement…", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 101148280d..793cf3c745 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "プロキシの変更は、これ以降に開くタブに適用されます。", "proxyRestart": "変更したプロキシをブラウザタブで使うには codeg を再起動してください。", "proxyUnsupported": "このバージョンの macOS では利用できません(macOS 14 以降が必要です)。", - "proxyUnusable": "設定されたプロキシはブラウザタブで使用できない種類です(http と socks5 のみ対応)。" + "proxyUnusable": "設定されたプロキシはブラウザタブで使用できない種類です(http と socks5 のみ対応)。", + "suspendTitle": "バックグラウンドのタブを解放", + "suspendHint": "30 分間バックグラウンドにあるブラウザタブのメモリを解放します。戻ると再読み込みされ、スクロール位置と履歴は失われます。" }, "LogsSettings": { "loading": "読み込み中…", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b2f2709a35..2c43cb7620 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "변경된 프록시는 이후에 여는 탭부터 적용됩니다.", "proxyRestart": "변경된 프록시를 브라우저 탭에서 사용하려면 codeg를 다시 시작하세요.", "proxyUnsupported": "이 macOS 버전에서는 사용할 수 없습니다(macOS 14 이상 필요).", - "proxyUnusable": "설정된 프록시는 브라우저 탭에서 사용할 수 없는 종류입니다(http 및 socks5만 지원)." + "proxyUnusable": "설정된 프록시는 브라우저 탭에서 사용할 수 없는 종류입니다(http 및 socks5만 지원).", + "suspendTitle": "백그라운드 탭 내려놓기", + "suspendHint": "30분 동안 백그라운드에 있는 브라우저 탭의 메모리를 해제합니다. 다시 전환하면 새로 불러오며, 스크롤 위치와 방문 기록은 사라집니다." }, "LogsSettings": { "loading": "불러오는 중…", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index bd1b24b935..888a6d6504 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "Uma alteração de proxy aplica-se aos separadores abertos a partir de agora.", "proxyRestart": "Reinicie o codeg para os separadores do navegador usarem o proxy alterado.", "proxyUnsupported": "Indisponível nesta versão do macOS (é necessária a 14 ou posterior).", - "proxyUnusable": "O proxy configurado não é de um tipo que os separadores do navegador possam usar (apenas http e socks5)." + "proxyUnusable": "O proxy configurado não é de um tipo que os separadores do navegador possam usar (apenas http e socks5).", + "suspendTitle": "Descarregar abas em segundo plano", + "suspendHint": "Libera a memória das abas do navegador que ficam 30 minutos em segundo plano. Elas recarregam quando você volta; a posição de rolagem e o histórico são perdidos." }, "LogsSettings": { "loading": "Carregando…", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e3d36b5c81..09823baa27 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "代理变更对此后打开的标签页生效。", "proxyRestart": "代理已变更,重启 codeg 后浏览器标签页才会使用新代理。", "proxyUnsupported": "当前 macOS 版本不支持(需要 macOS 14 或更高)。", - "proxyUnusable": "配置的代理不是浏览器标签页能使用的类型(仅支持 http 和 socks5)。" + "proxyUnusable": "配置的代理不是浏览器标签页能使用的类型(仅支持 http 和 socks5)。", + "suspendTitle": "卸载后台标签页", + "suspendHint": "释放在后台停留 30 分钟的浏览器标签页所占的内存。切回时会重新加载;滚动位置和历史记录会丢失。" }, "LogsSettings": { "loading": "加载中…", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 82c55b59ae..4c22e1348f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4760,7 +4760,9 @@ "proxyNextTab": "代理變更對此後開啟的分頁生效。", "proxyRestart": "代理已變更,重新啟動 codeg 後瀏覽器分頁才會使用新代理。", "proxyUnsupported": "目前的 macOS 版本不支援(需要 macOS 14 或更新)。", - "proxyUnusable": "設定的代理不是瀏覽器分頁能使用的類型(僅支援 http 與 socks5)。" + "proxyUnusable": "設定的代理不是瀏覽器分頁能使用的類型(僅支援 http 與 socks5)。", + "suspendTitle": "卸載背景分頁", + "suspendHint": "釋放在背景停留 30 分鐘的瀏覽器分頁所佔的記憶體。切回時會重新載入;捲動位置與歷史記錄會遺失。" }, "LogsSettings": { "loading": "載入中…", diff --git a/src/lib/browser/browser-prefs.ts b/src/lib/browser/browser-prefs.ts index f066e4010e..d190b22c46 100644 --- a/src/lib/browser/browser-prefs.ts +++ b/src/lib/browser/browser-prefs.ts @@ -35,6 +35,10 @@ export interface BrowserPrefsSnapshot { devtools: boolean surfaceOverride: SurfaceOverride firstOpenSeen: boolean + /** Release the native surface of tabs that stay in the background for a + * while (they reload when shown again). Off by default: a page's state + * is worth more than its memory unless the user says otherwise. */ + suspendBackgroundTabs: boolean } export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ @@ -48,6 +52,7 @@ export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ devtools: false, surfaceOverride: "auto", firstOpenSeen: false, + suspendBackgroundTabs: false, }) as BrowserPrefsSnapshot const KEY_PREFIX = "browser:" @@ -59,6 +64,7 @@ function targetKey(source: LinkSource): string { const DEVTOOLS_KEY = `${KEY_PREFIX}devtools` const SURFACE_KEY = `${KEY_PREFIX}surface-override` const FIRST_OPEN_KEY = `${KEY_PREFIX}first-open-seen` +const SUSPEND_KEY = `${KEY_PREFIX}suspend-background-tabs` function readRaw(key: string): string | null { if (typeof window === "undefined") return null @@ -91,6 +97,7 @@ function read(): BrowserPrefsSnapshot { parseSurface(readRaw(SURFACE_KEY)) ?? DEFAULT_BROWSER_PREFS.surfaceOverride, firstOpenSeen: readRaw(FIRST_OPEN_KEY) === "true", + suspendBackgroundTabs: readRaw(SUSPEND_KEY) === "true", } } @@ -138,6 +145,10 @@ export function markBrowserFirstOpenSeen(): void { write(FIRST_OPEN_KEY, "true") } +export function setBrowserSuspendBackgroundTabs(enabled: boolean): void { + write(SUSPEND_KEY, enabled ? "true" : null) +} + export function subscribeBrowserPrefs(listener: () => void): () => void { if (typeof window === "undefined") return () => {} const onChange = () => listener() @@ -179,6 +190,7 @@ export function resetBrowserPrefsForTests(): void { localStorage.removeItem(DEVTOOLS_KEY) localStorage.removeItem(SURFACE_KEY) localStorage.removeItem(FIRST_OPEN_KEY) + localStorage.removeItem(SUSPEND_KEY) } catch { /* ignore */ } diff --git a/src/lib/browser/browser-tab-persistence.test.ts b/src/lib/browser/browser-tab-persistence.test.ts new file mode 100644 index 0000000000..ce86a45eaf --- /dev/null +++ b/src/lib/browser/browser-tab-persistence.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" + +import { + BROWSER_TABS_STORAGE_VERSION, + browserTabsStorageKey, + readPersistedBrowserTabs, + samePersistedBrowserTabs, + selectBrowserTabsToSuspend, + snapshotBrowserTabs, + writePersistedBrowserTabs, + type PersistedBrowserTab, +} from "./browser-tab-persistence" +import type { BrowserTabState } from "./types" + +function browserTab( + id: string, + initialUrl: string, + over: Partial<FileWorkspaceTab> = {} +): FileWorkspaceTab { + return { + id: `browser:${id}`, + kind: "browser", + folderId: 1, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: false, + readonly: true, + browser: { initialUrl, openerTabId: null }, + ...over, + } as FileWorkspaceTab +} + +function fileTab(path: string): FileWorkspaceTab { + return { + id: `file:${path}`, + kind: "file", + folderId: null, + title: "a.ts", + description: null, + path, + language: "typescript", + content: "x", + loading: false, + } as FileWorkspaceTab +} + +function state(over: Partial<BrowserTabState> = {}): BrowserTabState { + return { + tabId: "abc", + ownerWindow: "main", + surface: "child", + channel: "native", + url: "https://example.com/deep", + requestedUrl: "https://example.com/deep", + title: "Deep page", + favicon: null, + loading: false, + canGoBack: false, + canGoForward: false, + origin: "https://example.com", + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + ...over, + } +} + +const KEY = browserTabsStorageKey("main") + +beforeEach(() => { + localStorage.clear() +}) + +describe("browser tab persistence", () => { + it("keys storage per window label", () => { + expect(browserTabsStorageKey("main")).toBe("browser:tabs:main") + expect(browserTabsStorageKey("remote-workspace-3")).toBe( + "browser:tabs:remote-workspace-3" + ) + }) + + it("round-trips a list and removes the key when it becomes empty", () => { + const tabs: PersistedBrowserTab[] = [ + { url: "https://example.com/a", title: "A", folderId: 2 }, + { url: "http://localhost:3000/", title: "", folderId: null }, + ] + writePersistedBrowserTabs(tabs, "main") + expect(readPersistedBrowserTabs("main")).toEqual(tabs) + + writePersistedBrowserTabs([], "main") + expect(localStorage.getItem(KEY)).toBeNull() + expect(readPersistedBrowserTabs("main")).toEqual([]) + }) + + // A stored list is user data from a previous run: it must never throw, and + // one bad entry must not lose the good ones. + it("drops unusable entries instead of the whole list", () => { + localStorage.setItem( + KEY, + JSON.stringify({ + version: BROWSER_TABS_STORAGE_VERSION, + tabs: [ + { url: "https://ok.example/", title: "ok", folderId: 1 }, + { url: "file:///etc/passwd", title: "no", folderId: 1 }, + { url: "javascript:alert(1)", title: "no", folderId: 1 }, + { url: "not a url", title: "no", folderId: 1 }, + null, + { title: "no url" }, + { url: "https://ok2.example/", title: 42, folderId: "x" }, + ], + }) + ) + expect(readPersistedBrowserTabs("main")).toEqual([ + { url: "https://ok.example/", title: "ok", folderId: 1 }, + { url: "https://ok2.example/", title: "", folderId: null }, + ]) + }) + + it("ignores a payload from another version or shape", () => { + localStorage.setItem(KEY, "{oops") + expect(readPersistedBrowserTabs("main")).toEqual([]) + localStorage.setItem( + KEY, + JSON.stringify({ version: 99, tabs: [{ url: "https://x.example/" }] }) + ) + expect(readPersistedBrowserTabs("main")).toEqual([]) + localStorage.setItem(KEY, JSON.stringify({ version: 1, tabs: "nope" })) + expect(readPersistedBrowserTabs("main")).toEqual([]) + }) + + it("snapshots browser tabs in strip order at their live page", () => { + const tabs = [ + fileTab("/repo/a.ts"), + browserTab("one", "https://example.com/"), + browserTab("two", "http://localhost:3000/", { folderId: null }), + // Never loaded: falls back to the address it was opened with. + browserTab("three", "https://never.example/", { title: "never.example" }), + ] + const states = new Map<string, BrowserTabState>([ + ["browser:one", state()], + [ + "browser:two", + state({ + url: "", + requestedUrl: "http://localhost:3000/app", + title: "", + }), + ], + ]) + expect(snapshotBrowserTabs(tabs, (id) => states.get(id) ?? null)).toEqual([ + { url: "https://example.com/deep", title: "Deep page", folderId: 1 }, + // No committed URL yet, no title: the requested address and the record. + { + url: "http://localhost:3000/app", + title: "example.com", + folderId: null, + }, + { url: "https://never.example/", title: "never.example", folderId: 1 }, + ]) + }) + + it("skips a tab whose live address is not a web page", () => { + const tabs = [browserTab("one", "https://example.com/")] + const states = new Map([ + ["browser:one", state({ url: "about:blank", requestedUrl: "" })], + ]) + // `about:blank` is what a surface shows before its first navigation; + // restoring it would bring back a blank tab. + expect(snapshotBrowserTabs(tabs, (id) => states.get(id) ?? null)).toEqual([ + { url: "https://example.com/", title: "Deep page", folderId: 1 }, + ]) + }) + + it("compares snapshots field by field", () => { + const a: PersistedBrowserTab[] = [ + { url: "https://a.example/", title: "A", folderId: 1 }, + ] + expect(samePersistedBrowserTabs(a, [...a])).toBe(true) + expect( + samePersistedBrowserTabs(a, [ + { url: "https://a.example/", title: "A2", folderId: 1 }, + ]) + ).toBe(false) + expect( + samePersistedBrowserTabs(a, [ + { url: "https://a.example/", title: "A", folderId: 2 }, + ]) + ).toBe(false) + expect(samePersistedBrowserTabs(a, [])).toBe(false) + }) +}) + +describe("selectBrowserTabsToSuspend", () => { + const now = 1_000_000 + + it("releases only loaded tabs that have been off screen long enough", () => { + expect( + selectBrowserTabsToSuspend( + [ + // On screen right now. + { id: "visible", hiddenAt: null, loaded: true }, + // Hidden, but not for long enough. + { id: "recent", hiddenAt: now - 5_000, loaded: true }, + { id: "idle", hiddenAt: now - 60_000, loaded: true }, + // Already unloaded (restored, or suspended earlier). + { id: "unloaded", hiddenAt: now - 60_000, loaded: false }, + // Never shown: no surface was ever created for it. + { id: "never", hiddenAt: undefined, loaded: false }, + ], + now, + 30_000 + ) + ).toEqual(["idle"]) + }) + + it("is exact at the threshold", () => { + const at = [{ id: "x", hiddenAt: now - 30_000, loaded: true }] + expect(selectBrowserTabsToSuspend(at, now, 30_000)).toEqual(["x"]) + expect(selectBrowserTabsToSuspend(at, now, 30_001)).toEqual([]) + }) +}) diff --git a/src/lib/browser/browser-tab-persistence.ts b/src/lib/browser/browser-tab-persistence.ts new file mode 100644 index 0000000000..01949aed1c --- /dev/null +++ b/src/lib/browser/browser-tab-persistence.ts @@ -0,0 +1,194 @@ +// What survives a restart of a browser tab: its page and title, per window. +// +// File tabs are session-only, browser tabs are not: a page is cheap to bring +// back (one URL), and the dev-server page next to the chat is exactly what a +// user expects to find again after relaunching. Records come back "not +// loaded" — a native surface is created for one when it is first shown — so +// restoring twenty tabs costs twenty small records and nothing else. +// +// One localStorage key per window label (`main`, `remote-workspace-*`): each +// workspace window owns its own tabs. The stored order is the strip order. +// Pure functions here; `BrowserTabsPersistence` (components/browser) does +// the wiring. + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" + +import type { BrowserTabState } from "./types" +import { getCurrentWindowLabel } from "./window-label" + +export interface PersistedBrowserTab { + /** The page the tab was on (its live URL, falling back to the address it + * was opened with while nothing has committed). */ + url: string + /** Page title; "" when unknown (the restorer falls back to the host). */ + title: string + folderId: number | null +} + +const KEY_PREFIX = "browser:tabs:" +export const BROWSER_TABS_STORAGE_VERSION = 1 + +interface StoredShape { + version: number + tabs: PersistedBrowserTab[] +} + +export function browserTabsStorageKey( + windowLabel: string = getCurrentWindowLabel() +): string { + return `${KEY_PREFIX}${windowLabel}` +} + +function isWebUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === "http:" || parsed.protocol === "https:" + } catch { + return false + } +} + +/** Accept only what a restorer can act on; everything else is dropped, never + * thrown on — a corrupt entry must not take the whole list with it. */ +function sanitize(raw: unknown): PersistedBrowserTab | null { + if (!raw || typeof raw !== "object") return null + const { url, title, folderId } = raw as Record<string, unknown> + if (typeof url !== "string" || !isWebUrl(url)) return null + return { + url, + title: typeof title === "string" ? title : "", + folderId: + typeof folderId === "number" && Number.isInteger(folderId) + ? folderId + : null, + } +} + +export function readPersistedBrowserTabs( + windowLabel?: string +): PersistedBrowserTab[] { + if (typeof window === "undefined") return [] + let raw: string | null + try { + raw = localStorage.getItem(browserTabsStorageKey(windowLabel)) + } catch { + return [] + } + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as Partial<StoredShape> | null + if ( + !parsed || + parsed.version !== BROWSER_TABS_STORAGE_VERSION || + !Array.isArray(parsed.tabs) + ) { + return [] + } + return parsed.tabs.flatMap((entry) => { + const clean = sanitize(entry) + return clean ? [clean] : [] + }) + } catch { + return [] + } +} + +export function writePersistedBrowserTabs( + tabs: readonly PersistedBrowserTab[], + windowLabel?: string +): void { + if (typeof window === "undefined") return + const key = browserTabsStorageKey(windowLabel) + try { + if (tabs.length === 0) { + localStorage.removeItem(key) + return + } + const stored: StoredShape = { + version: BROWSER_TABS_STORAGE_VERSION, + tabs: tabs.map((tab) => ({ + url: tab.url, + title: tab.title, + folderId: tab.folderId, + })), + } + localStorage.setItem(key, JSON.stringify(stored)) + } catch { + /* quota / privacy mode: the tabs simply do not survive this run */ + } +} + +/** + * The persisted view of the open tabs: browser records in strip order, each + * at its live page when there is one. `stateOf` is the tab store's getter, + * injected so this stays a pure function of its inputs. + * + * The address is the first WEB url among "where the tab is", "where it was + * going" and "what it was opened with": a page that navigated itself to + * `about:blank` still comes back as the page the user opened, and a popup + * that only ever was `about:blank` (its content written by its opener) is + * dropped, since there is nothing to reload. + */ +export function snapshotBrowserTabs( + fileTabs: readonly FileWorkspaceTab[], + stateOf: (workspaceTabId: string) => BrowserTabState | null +): PersistedBrowserTab[] { + const out: PersistedBrowserTab[] = [] + for (const tab of fileTabs) { + if (tab.kind !== "browser") continue + const state = stateOf(tab.id) + const url = [state?.url, state?.requestedUrl, tab.browser.initialUrl].find( + (candidate) => candidate && isWebUrl(candidate) + ) + if (!url) continue + out.push({ + url, + title: state?.title || tab.title, + folderId: tab.folderId, + }) + } + return out +} + +export function samePersistedBrowserTabs( + a: readonly PersistedBrowserTab[], + b: readonly PersistedBrowserTab[] +): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if ( + a[i].url !== b[i].url || + a[i].title !== b[i].title || + a[i].folderId !== b[i].folderId + ) { + return false + } + } + return true +} + +/** Idle threshold of the optional background unload: a loaded tab whose + * surface host has been unmounted this long is released. */ +export const BROWSER_TAB_SUSPEND_AFTER_MS = 30 * 60 * 1000 + +export interface SuspendCandidate { + id: string + /** From the tab store: `null` while on screen, `undefined` if never shown. */ + hiddenAt: number | null | undefined + loaded: boolean +} + +/** Which tabs the background unload should release right now. Only loaded + * tabs that were shown once and have been off screen for the threshold. */ +export function selectBrowserTabsToSuspend( + candidates: readonly SuspendCandidate[], + now: number, + idleMs: number = BROWSER_TAB_SUSPEND_AFTER_MS +): string[] { + return candidates + .filter( + (c) => + c.loaded && typeof c.hiddenAt === "number" && now - c.hiddenAt >= idleMs + ) + .map((c) => c.id) +} diff --git a/src/lib/browser/browser-tab-store.test.ts b/src/lib/browser/browser-tab-store.test.ts index 86e7e45de1..0f113ea3d2 100644 --- a/src/lib/browser/browser-tab-store.test.ts +++ b/src/lib/browser/browser-tab-store.test.ts @@ -9,8 +9,13 @@ vi.mock("./browser-api", () => ({ browserClose: api.browserClose })) vi.mock("@/lib/transport", () => ({ isDesktop: api.isDesktop })) import { + browserTabHiddenAt, browserWorkspaceTabId, + claimSurfaceCreation, + forgetSurfaceCreation, getBrowserTabState, + markBrowserTabHidden, + markBrowserTabShown, releaseBrowserTab, removeBrowserTabState, resetBrowserTabStoreForTests, @@ -99,4 +104,34 @@ describe("browser tab store", () => { releaseBrowserTab("file:%2Fx") expect(api.browserClose).toHaveBeenCalledTimes(1) }) + + // The ledger is what stops a StrictMode double effect (or a re-mounted + // host) from creating a second webview for one tab. + it("hands the surface-creation claim to exactly one caller until released", () => { + expect(claimSurfaceCreation("abc")).toBe(true) + expect(claimSurfaceCreation("abc")).toBe(false) + forgetSurfaceCreation("abc") + expect(claimSurfaceCreation("abc")).toBe(true) + }) + + // Releasing a tab returns it to "not loaded": the next host that mounts + // for it creates a fresh surface. This is what makes a suspended (or + // restored) tab resumable through the same code path. + it("releasing a tab frees its claim", () => { + setBrowserTabState(state()) + expect(claimSurfaceCreation("abc")).toBe(true) + releaseBrowserTab("browser:abc") + expect(claimSurfaceCreation("abc")).toBe(true) + }) + + it("stamps when a tab left the screen", () => { + expect(browserTabHiddenAt("browser:abc")).toBeUndefined() + markBrowserTabShown("browser:abc") + expect(browserTabHiddenAt("browser:abc")).toBeNull() + markBrowserTabHidden("browser:abc") + expect(typeof browserTabHiddenAt("browser:abc")).toBe("number") + // Forgetting the tab forgets the stamp with it. + removeBrowserTabState("browser:abc") + expect(browserTabHiddenAt("browser:abc")).toBeUndefined() + }) }) diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 515dce1d9f..5b8a0b1985 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -18,6 +18,47 @@ type Listener = () => void const states = new Map<string, BrowserTabState>() const listeners = new Set<Listener>() +// Backend ids whose surface this document has asked the backend to create. +// The surface host consults it so a StrictMode double effect or a re-mount of +// the same tab never asks twice: the webview lives as long as the tab record, +// not as long as the host component. Released together with the state, which +// is what lets a suspended tab be brought back through the same host. +const createdSurfaces = new Set<string>() + +/** Record that a surface is being created for `backendTabId`; false when it + * already was. */ +export function claimSurfaceCreation(backendTabId: string): boolean { + if (createdSurfaces.has(backendTabId)) return false + createdSurfaces.add(backendTabId) + return true +} + +export function forgetSurfaceCreation(backendTabId: string): void { + createdSurfaces.delete(backendTabId) +} + +// When each tab's surface host last went away (`null` while one is mounted). +// A host is mounted exactly while the tab is on screen — the active tab of a +// pane or the viewer drawer — so this is "how long has this page been in the +// background", which the optional background unload is based on. +const hiddenAt = new Map<string, number | null>() + +export function markBrowserTabShown(workspaceTabId: string): void { + hiddenAt.set(workspaceTabId, null) +} + +export function markBrowserTabHidden(workspaceTabId: string): void { + hiddenAt.set(workspaceTabId, Date.now()) +} + +/** Milliseconds-since-epoch the tab left the screen; `null` while it is on + * screen; `undefined` for a tab that was never shown in this document. */ +export function browserTabHiddenAt( + workspaceTabId: string +): number | null | undefined { + return hiddenAt.get(workspaceTabId) +} + function notify(): void { for (const listener of [...listeners]) listener() } @@ -44,6 +85,7 @@ export function setBrowserTabState(state: BrowserTabState): void { export function removeBrowserTabState(workspaceTabId: string): void { const hadNotice = notices.delete(workspaceTabId) + hiddenAt.delete(workspaceTabId) if (states.delete(workspaceTabId) || hadNotice) notify() } @@ -72,13 +114,17 @@ export function useBrowserTabState( /** * Tear down a tab's native surface and forget its state. Idempotent on the * backend side, so calling it for a tab that never got a surface is fine. + * Afterwards the tab record is back to "not loaded": a surface host mounting + * for it creates a fresh surface (that is how a suspended tab resumes). */ export function releaseBrowserTab(workspaceTabId: string): void { removeBrowserTabState(workspaceTabId) const backendId = workspaceTabId.startsWith("browser:") ? decodeURIComponent(workspaceTabId.slice("browser:".length)) : null - if (backendId && isDesktop()) { + if (!backendId) return + forgetSurfaceCreation(backendId) + if (isDesktop()) { void browserClose(backendId).catch(() => { /* already gone */ }) @@ -117,6 +163,8 @@ export function resetBrowserTabStoreForTests(): void { states.clear() notices.clear() listeners.clear() + createdSurfaces.clear() + hiddenAt.clear() } function shallowEqualState(a: BrowserTabState, b: BrowserTabState): boolean { diff --git a/src/lib/closed-tab-stack.test.ts b/src/lib/closed-tab-stack.test.ts index dc351f3ce2..9c0e982d43 100644 --- a/src/lib/closed-tab-stack.test.ts +++ b/src/lib/closed-tab-stack.test.ts @@ -6,6 +6,7 @@ import { popClosedTab, pushClosedTab, resetClosedTabStackForTests, + snapshotBrowserTab, snapshotConversationTab, snapshotFileTab, } from "./closed-tab-stack" @@ -132,6 +133,24 @@ describe("closed tab stack", () => { }) }) + // A browser tab reopens at the page it was showing, not the address it was + // opened with — the caller reads that from the live state. + it("records a browser tab at its live page", () => { + expect( + snapshotBrowserTab( + { id: "browser:abc", folderId: 3 }, + "https://example.com/deep", + "Deep page" + ) + ).toEqual({ + kind: "browser", + key: "browser:abc", + url: "https://example.com/deep", + title: "Deep page", + folderId: 3, + }) + }) + // A diff tab carries the path it compares, but reopening goes through // `openFilePreview` — restoring one would silently swap the diff for the // source editor, so it is not recorded at all. diff --git a/src/lib/closed-tab-stack.ts b/src/lib/closed-tab-stack.ts index 90e9c42d70..58dd97d307 100644 --- a/src/lib/closed-tab-stack.ts +++ b/src/lib/closed-tab-stack.ts @@ -22,7 +22,20 @@ export type ClosedFileTab = { folderId: number | null } -export type ClosedWorkspaceTab = ClosedConversationTab | ClosedFileTab +export type ClosedBrowserTab = { + kind: "browser" + /** The closed tab's id. Identity for the repeat-push guard in `pushClosedTab`. */ + key: string + /** The page the tab showed when it was closed (not the one it opened with). */ + url: string + title: string + folderId: number | null +} + +export type ClosedWorkspaceTab = + | ClosedConversationTab + | ClosedFileTab + | ClosedBrowserTab let stack: ClosedWorkspaceTab[] = [] @@ -116,3 +129,16 @@ export function snapshotFileTab(tab: { if (!tab.path) return null return { kind: "file", key: tab.id, path: tab.path, folderId: tab.folderId } } + +/** + * A browser tab reopens at the page it was showing, which is the live URL + * the caller reads from the tab store — the record itself only knows the + * address the tab was opened with. + */ +export function snapshotBrowserTab( + tab: { id: string; folderId: number | null }, + url: string, + title: string +): ClosedBrowserTab { + return { kind: "browser", key: tab.id, url, title, folderId: tab.folderId } +} From 257be7f87b34925b691922c2eb9f30da07cf5c7a Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 10:05:36 +0800 Subject: [PATCH 18/79] feat(browser): let pages download files into the downloads folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 refused every download because there was no destination policy and no UI. Both now exist: the engine transfers the file, the host decides only where it may land and reports what happened. - `browser/downloads.rs`: destination = the OS downloads folder (never the app's data dir). The name comes from the server (`Content-Disposition`), so only its last component is used, and only after `safe_file_name` strips separators, parent hops, drive letters, control characters and leading dots — a suggested name can only ever name a file directly in that folder. An existing file is never replaced: ` (1)`, ` (2)`… until the path is free. - Records live in managed state and reach the frontend on `browser://download` when a transfer starts and when it ends. macOS reports no path on completion (WebKit API limit), so the destination chosen at request time is remembered and matched back by URL. - Download bar per tab, hydrated from `browser_list_downloads` so a frontend reload does not lose a running transfer. It offers "show in folder" for a completed file and never opens anything. - The settings section says where downloads land. --- src-tauri/src/browser/downloads.rs | 359 ++++++++++++++++++ src-tauri/src/browser/events.rs | 5 + src-tauri/src/browser/mod.rs | 3 + src-tauri/src/browser/smoke.rs | 9 + src-tauri/src/browser/surface_child.rs | 15 +- src-tauri/src/browser/surface_window.rs | 20 +- src-tauri/src/browser/types.rs | 2 + src-tauri/src/commands/browser.rs | 20 + src-tauri/src/lib.rs | 3 + .../browser/browser-events-bridge.test.tsx | 39 +- .../browser/browser-events-bridge.tsx | 21 + .../browser/browser-status-layer.test.tsx | 79 +++- .../browser/browser-status-layer.tsx | 80 +++- src/components/browser/browser-tab-view.tsx | 2 + .../browser/browser-tabs-persistence.test.tsx | 3 + src/components/settings/browser-settings.tsx | 19 +- src/hooks/use-open-url-target.test.tsx | 1 + src/i18n/messages/ar.json | 11 +- src/i18n/messages/de.json | 11 +- src/i18n/messages/en.json | 11 +- src/i18n/messages/es.json | 11 +- src/i18n/messages/fr.json | 11 +- src/i18n/messages/ja.json | 11 +- src/i18n/messages/ko.json | 11 +- src/i18n/messages/pt.json | 11 +- src/i18n/messages/zh-CN.json | 11 +- src/i18n/messages/zh-TW.json | 11 +- src/lib/browser/browser-api.ts | 12 + .../browser/browser-downloads-store.test.ts | 89 +++++ src/lib/browser/browser-downloads-store.ts | 128 +++++++ src/lib/browser/types.test.ts | 1 + src/lib/browser/types.ts | 16 + 32 files changed, 1013 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/browser/downloads.rs create mode 100644 src/lib/browser/browser-downloads-store.test.ts create mode 100644 src/lib/browser/browser-downloads-store.ts diff --git a/src-tauri/src/browser/downloads.rs b/src-tauri/src/browser/downloads.rs new file mode 100644 index 0000000000..1593037de2 --- /dev/null +++ b/src-tauri/src/browser/downloads.rs @@ -0,0 +1,359 @@ +//! Downloads started by a browser tab. +//! +//! P1 refused every download outright (no destination policy, no UI). Here the +//! host takes the decision the engine offers it: where the file lands, and +//! that nothing is ever overwritten or opened afterwards. The engine does the +//! transfer; we only choose the path, remember the record and tell the +//! frontend, which shows a bar with "show in folder". +//! +//! Two platform facts shape this module: +//! - The name in the engine's suggested destination comes from the SERVER +//! (`Content-Disposition`) — untrusted. Only its last component is used, and +//! only after `safe_file_name` has stripped anything that could climb out of +//! the downloads directory. +//! - On macOS the finished callback carries no path at all (wry / WebKit API +//! limitation), so the destination chosen at request time is remembered here +//! and matched back by URL when the transfer ends. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +use super::events; + +pub const DOWNLOAD_EVENT: &str = "browser://download"; + +/// How many finished records are kept for the UI; the bar shows the last few +/// and a download is a file on disk afterwards, not a thing to scroll back to. +const HISTORY_LIMIT: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DownloadState { + Started, + Completed, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserDownload { + pub id: String, + /// Tab the download started in; the bar shows it there. + pub tab_id: String, + pub url: String, + pub file_name: String, + /// Absolute path the engine was told to write to. + pub path: String, + pub state: DownloadState, +} + +#[derive(Default)] +pub struct BrowserDownloads { + entries: Mutex<Vec<BrowserDownload>>, +} + +static DOWNLOAD_SEQ: AtomicU64 = AtomicU64::new(0); + +impl BrowserDownloads { + fn lock(&self) -> std::sync::MutexGuard<'_, Vec<BrowserDownload>> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn push(&self, download: BrowserDownload) { + let mut entries = self.lock(); + entries.push(download); + if entries.len() > HISTORY_LIMIT { + let overflow = entries.len() - HISTORY_LIMIT; + entries.drain(0..overflow); + } + } + + /// Finish the oldest in-flight download of `url` (the only identity the + /// completion callback carries) and return the updated record. + fn finish(&self, url: &str, success: bool, path: Option<PathBuf>) -> Option<BrowserDownload> { + let mut entries = self.lock(); + let entry = entries + .iter_mut() + .find(|d| d.url == url && d.state == DownloadState::Started)?; + entry.state = if success { + DownloadState::Completed + } else { + DownloadState::Failed + }; + // Windows / Linux report where the file actually landed; macOS does + // not, and keeps the path chosen when the download was requested. + if let Some(path) = path { + if success && !path.as_os_str().is_empty() { + entry.file_name = file_name_of(&path); + entry.path = path.to_string_lossy().to_string(); + } + } + Some(entry.clone()) + } + + pub fn list(&self) -> Vec<BrowserDownload> { + self.lock().clone() + } + + pub fn clear(&self) { + self.lock().clear(); + } +} + +fn file_name_of(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() +} + +/// Where downloads land: the OS download folder, else `~/Downloads`. Not +/// configurable in this pass, and deliberately NOT inside the app's data +/// directory — a downloaded file belongs to the user, not to codeg. +pub fn downloads_dir() -> PathBuf { + if let Some(dir) = dirs::download_dir() { + return dir; + } + dirs::home_dir() + .map(|home| home.join("Downloads")) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// The server-suggested name, reduced to something that can only ever name a +/// file directly inside the downloads directory. Separators, parent hops, +/// NUL / control characters and leading dots are all removed; an empty or +/// hopeless name becomes `download`. +pub fn safe_file_name(suggested: &str) -> String { + // Both separators on every platform: a Windows-style name arriving on + // macOS must not become one file called `..\\..\\x`. + let last = suggested + .rsplit(['/', '\\']) + .next() + .unwrap_or(suggested) + .trim(); + let cleaned: String = last + .chars() + .filter(|c| !c.is_control() && !matches!(c, '/' | '\\' | ':' | '\0')) + .collect(); + let cleaned = cleaned.trim_matches(|c: char| c == '.' || c.is_whitespace()); + if cleaned.is_empty() || cleaned == ".." { + return "download".to_string(); + } + // Long names are a filesystem error, not a security problem; keep the + // extension by trimming the stem. + const MAX: usize = 120; + if cleaned.chars().count() <= MAX { + return cleaned.to_string(); + } + let path = Path::new(cleaned); + let ext = path + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + let stem: String = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default() + .chars() + .take(MAX.saturating_sub(ext.chars().count())) + .collect(); + format!("{stem}{ext}") +} + +/// `dir/name`, with ` (1)`, ` (2)`… appended before the extension until the +/// path is free. A download NEVER replaces a file that is already there. +pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { + let candidate = dir.join(file_name); + if !candidate.exists() { + return candidate; + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| file_name.to_string()); + let ext = path + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + for counter in 1..10_000 { + let candidate = dir.join(format!("{stem} ({counter}){ext}")); + if !candidate.exists() { + return candidate; + } + } + // Pathological directory; a timestamp is still unique enough to write to. + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default(); + dir.join(format!("{stem} ({stamp}){ext}")) +} + +/// A download was requested in `tab_id`. Rewrites `destination` to a free path +/// under the downloads directory and records the download. Returns false when +/// the directory cannot be created — the engine then cancels, which is the +/// honest outcome (there is nowhere to write). +pub fn requested(app: &AppHandle, tab_id: &str, url: &str, destination: &mut PathBuf) -> bool { + let dir = downloads_dir(); + if let Err(err) = std::fs::create_dir_all(&dir) { + tracing::warn!( + "[browser] refusing the download of {url}: {} is not writable ({err})", + dir.display() + ); + return false; + } + // The engine already suggested a name (and on macOS a whole path); only + // its last component is used, and only after sanitising. + let file_name = safe_file_name(&file_name_of(destination)); + let path = unique_path(&dir, &file_name); + *destination = path.clone(); + + let seq = DOWNLOAD_SEQ.fetch_add(1, Ordering::SeqCst) + 1; + let download = BrowserDownload { + id: format!("dl-{seq}"), + tab_id: tab_id.to_string(), + url: url.to_string(), + file_name: file_name_of(&path), + path: path.to_string_lossy().to_string(), + state: DownloadState::Started, + }; + if let Some(downloads) = app.try_state::<BrowserDownloads>() { + downloads.push(download.clone()); + } + events::emit_download(app, &download); + true +} + +/// The engine finished (or gave up on) a download. +pub fn finished(app: &AppHandle, url: &str, path: Option<PathBuf>, success: bool) { + let Some(downloads) = app.try_state::<BrowserDownloads>() else { + return; + }; + if let Some(download) = downloads.finish(url, success, path) { + events::emit_download(app, &download); + } +} + +/// Where a tab's downloads go, for the settings section. +pub fn downloads_dir_display() -> String { + downloads_dir().to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_suggested_name_can_only_name_a_file_in_the_directory() { + assert_eq!(safe_file_name("report.pdf"), "report.pdf"); + // Path traversal in every shape the server can send. + assert_eq!(safe_file_name("../../etc/passwd"), "passwd"); + assert_eq!(safe_file_name("..\\..\\Windows\\system.ini"), "system.ini"); + assert_eq!(safe_file_name("/absolute/evil.sh"), "evil.sh"); + assert_eq!(safe_file_name(".."), "download"); + assert_eq!(safe_file_name("."), "download"); + assert_eq!(safe_file_name(""), "download"); + assert_eq!(safe_file_name(" "), "download"); + // A leading dot would make the file invisible; drive letters and NUL + // cannot survive either. + assert_eq!(safe_file_name(".bashrc"), "bashrc"); + assert_eq!(safe_file_name("C:\\x\\y.txt"), "y.txt"); + assert_eq!(safe_file_name("a\u{0}b.txt"), "ab.txt"); + assert_eq!(safe_file_name("line\nbreak.txt"), "linebreak.txt"); + } + + #[test] + fn a_long_name_keeps_its_extension() { + let name = safe_file_name(&format!("{}.tar.gz", "x".repeat(400))); + assert!(name.chars().count() <= 120, "{name}"); + assert!(name.ends_with(".gz"), "{name}"); + } + + #[test] + fn a_download_never_replaces_an_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let first = unique_path(dir.path(), "report.pdf"); + assert_eq!(first, dir.path().join("report.pdf")); + std::fs::write(&first, b"one").unwrap(); + + let second = unique_path(dir.path(), "report.pdf"); + assert_eq!(second, dir.path().join("report (1).pdf")); + std::fs::write(&second, b"two").unwrap(); + + assert_eq!( + unique_path(dir.path(), "report.pdf"), + dir.path().join("report (2).pdf") + ); + // The first file is untouched. + assert_eq!(std::fs::read(&first).unwrap(), b"one"); + } + + #[test] + fn an_extensionless_name_is_numbered_too() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("LICENSE"), b"x").unwrap(); + assert_eq!( + unique_path(dir.path(), "LICENSE"), + dir.path().join("LICENSE (1)") + ); + } + + #[test] + fn the_registry_finishes_the_matching_download_and_caps_its_history() { + let downloads = BrowserDownloads::default(); + let record = |id: &str, url: &str| BrowserDownload { + id: id.into(), + tab_id: "t1".into(), + url: url.into(), + file_name: "a.bin".into(), + path: "/tmp/a.bin".into(), + state: DownloadState::Started, + }; + downloads.push(record("dl-1", "https://example.com/a")); + downloads.push(record("dl-2", "https://example.com/b")); + // Same URL twice: the oldest still running finishes first. + downloads.push(record("dl-3", "https://example.com/a")); + + let done = downloads + .finish("https://example.com/a", true, Some(PathBuf::from("/tmp/z.bin"))) + .unwrap(); + assert_eq!(done.id, "dl-1"); + assert_eq!(done.state, DownloadState::Completed); + assert_eq!(done.file_name, "z.bin"); + let again = downloads.finish("https://example.com/a", false, None).unwrap(); + assert_eq!(again.id, "dl-3"); + assert_eq!(again.state, DownloadState::Failed); + assert!(downloads.finish("https://example.com/a", true, None).is_none()); + // A failed download keeps the path it was going to be written to. + assert_eq!(again.path, "/tmp/a.bin"); + + for i in 0..HISTORY_LIMIT + 5 { + downloads.push(record(&format!("x-{i}"), "https://example.com/c")); + } + assert_eq!(downloads.list().len(), HISTORY_LIMIT); + downloads.clear(); + assert!(downloads.list().is_empty()); + } + + #[test] + fn wire_names_are_camel_and_kebab() { + let json = serde_json::to_value(BrowserDownload { + id: "dl-1".into(), + tab_id: "t1".into(), + url: "https://example.com/a.bin".into(), + file_name: "a.bin".into(), + path: "/tmp/a.bin".into(), + state: DownloadState::Started, + }) + .unwrap(); + assert_eq!(json["tabId"], "t1"); + assert_eq!(json["fileName"], "a.bin"); + assert_eq!(json["state"], "started"); + } +} diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index 38c2aae9b9..35f09b18d4 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -5,6 +5,7 @@ use tauri::AppHandle; use crate::web::event_bridge::{emit_event, EventEmitter}; +use super::downloads::{BrowserDownload, DOWNLOAD_EVENT}; use super::types::{ BrowserClosedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, CLOSED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, STATE_EVENT, @@ -32,3 +33,7 @@ pub fn emit_popup(app: &AppHandle, payload: &BrowserPopupPayload) { pub fn emit_open_request(app: &AppHandle, payload: &BrowserOpenRequestPayload) { emit_event(&EventEmitter::Tauri(app.clone()), OPEN_REQUEST_EVENT, payload); } + +pub fn emit_download(app: &AppHandle, download: &BrowserDownload) { + emit_event(&EventEmitter::Tauri(app.clone()), DOWNLOAD_EVENT, download); +} diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index 3c34a862b7..5cab2aad14 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -14,6 +14,7 @@ //! - `types` — wire types shared with `src/lib/browser/types.ts` //! - `policy` — pure decisions (scheme allow-list, …) //! - `profile` — the tabs' own data store / directory and their proxy +//! - `downloads` — destination policy and records for page downloads //! - `registry` — tab id → surface + last known state //! - `surface` — the enum over the concrete surfaces and their common ops //! - `surface_child` / `surface_window` — the concrete builders @@ -25,6 +26,7 @@ //! `browser-smoke`, never in a release build) pub mod channel; +pub mod downloads; pub mod events; pub mod hooks; pub mod policy; @@ -44,6 +46,7 @@ pub mod shim; #[cfg(feature = "browser-smoke")] pub mod smoke; +pub use downloads::BrowserDownloads; pub use registry::BrowserRegistry; /// Label prefix of every browser tab webview / window. Nothing under this diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 5351f62a70..197afa92fc 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -398,6 +398,15 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .map_err(err_string)?; Ok(Value::Null) } + // Download records this run produced, oldest first. + "browser_downloads" => Ok(serde_json::to_value( + app.state::<crate::browser::BrowserDownloads>().list(), + ) + .unwrap_or(Value::Null)), + "browser_clear_downloads" => { + app.state::<crate::browser::BrowserDownloads>().clear(); + Ok(Value::Null) + } "browser_clear_data" => { browser_commands::clear_data_core(app, ®istry) .await diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 5d41ca59c2..562794bc86 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -523,8 +523,19 @@ fn configure_child<'a>( let id = tab_id.to_string(); move |title| hooks::title_changed(&app, &id, title) }) - // Downloads are refused until the download UI exists (P2). - .with_download_started_handler(|_url, _destination| false) + // The engine transfers the file; the host only decides where it may + // land (`downloads::requested` rewrites the path) and reports it. + .with_download_started_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |url, destination| super::downloads::requested(&app, &id, &url, destination) + }) + .with_download_completed_handler({ + let app = app.clone(); + move |url: String, path, success| { + super::downloads::finished(&app, &url, path, success) + } + }) .with_new_window_req_handler(new_window_handler(app.clone(), owner.clone(), tab_id.to_string())); #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/browser/surface_window.rs b/src-tauri/src/browser/surface_window.rs index be898c0cad..db71a4f765 100644 --- a/src-tauri/src/browser/surface_window.rs +++ b/src-tauri/src/browser/surface_window.rs @@ -2,9 +2,10 @@ //! (`parent`). The only surface on Linux, the fallback everywhere else, and //! the shape popups take when they cannot be adopted as tabs. -use tauri::webview::PageLoadEvent; +use tauri::webview::{DownloadEvent, PageLoadEvent}; use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent}; +use super::downloads; use super::events; use super::hooks; use super::policy; @@ -45,7 +46,22 @@ pub fn create( let tab_id = tab_id.to_string(); move |_window, title| hooks::title_changed(&app, &tab_id, title) }) - .on_download(|_webview, _event| false) + .on_download({ + let app = app.clone(); + let tab_id = tab_id.to_string(); + move |_webview, event| match event { + DownloadEvent::Requested { url, destination } => { + downloads::requested(&app, &tab_id, url.as_str(), destination) + } + DownloadEvent::Finished { url, path, success } => { + downloads::finished(&app, url.as_str(), path, success); + true + } + // `DownloadEvent` is non-exhaustive: a variant added upstream + // must not silently become "allowed". + _ => false, + } + }) .disable_drag_drop_handler() .zoom_hotkeys_enabled(true) .browser_extensions_enabled(false); diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index 3ebabe7405..c308ba679c 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -101,6 +101,8 @@ pub struct BrowserCapabilities { /// Browsing data lives apart from the app's own web storage. pub isolated_storage: bool, pub proxy: crate::browser::profile::BrowserProxyStatus, + /// Where a page's downloads land, for the settings section. + pub downloads_dir: String, } /// Caller's surface preference for `browser_open_tab`. diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index eced85a51c..e39206e4b5 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -7,6 +7,7 @@ use tauri::{AppHandle, Manager, State, WebviewWindow}; use tauri::Url; use crate::app_error::AppCommandError; +use crate::browser::downloads::{BrowserDownload, BrowserDownloads}; use crate::browser::registry::{BrowserRegistry, BrowserTab}; use crate::browser::surface::BrowserSurface; use crate::browser::types::{ @@ -61,6 +62,7 @@ pub fn capabilities() -> BrowserCapabilities { reasons, isolated_storage: crate::browser::profile::isolated_storage(), proxy: crate::browser::profile::proxy_status(), + downloads_dir: crate::browser::downloads::downloads_dir_display(), } } @@ -611,6 +613,24 @@ pub async fn browser_list_tabs( Ok(registry.list_for_owner(window.label())) } +/// Downloads this run started, oldest first. The frontend hydrates from it on +/// mount; afterwards `browser://download` keeps it current. +#[tauri::command] +pub async fn browser_list_downloads( + downloads: State<'_, BrowserDownloads>, +) -> Result<Vec<BrowserDownload>, AppCommandError> { + Ok(downloads.list()) +} + +/// Forget the records (the "dismiss" of the download bar). The files stay. +#[tauri::command] +pub async fn browser_clear_downloads( + downloads: State<'_, BrowserDownloads>, +) -> Result<(), AppCommandError> { + downloads.clear(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 450db44e44..5ddeb765ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -394,6 +394,7 @@ mod tauri_app { )) .manage(ConnectionManager::new()) .manage(crate::browser::BrowserRegistry::default()) + .manage(crate::browser::BrowserDownloads::default()) .manage(TerminalManager::new()) .manage(ChatChannelManager::new()) .manage(windows::SettingsWindowState::new()) @@ -1206,6 +1207,8 @@ mod tauri_app { browser_commands::browser_get_state, browser_commands::browser_list_tabs, browser_commands::browser_clear_data, + browser_commands::browser_list_downloads, + browser_commands::browser_clear_downloads, conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 8ef29eb1b3..3914d02622 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => { reasons: [], isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, + downloadsDir: "/Users/dev/Downloads", }) ), subscribe: vi.fn((event: string, handler: Handler) => { @@ -37,6 +38,18 @@ const mocks = vi.hoisted(() => { browserListTabs: vi.fn(() => Promise.resolve([{ tabId: "stale-1" }, { tabId: "stale-2" }]) ), + browserListDownloads: vi.fn(() => + Promise.resolve([ + { + id: "dl-1", + tabId: "t1", + url: "https://example.com/a.bin", + fileName: "a.bin", + path: "/Users/dev/Downloads/a.bin", + state: "completed", + }, + ]) + ), } }) @@ -44,6 +57,7 @@ vi.mock("@/lib/browser/browser-api", () => ({ browserCapabilities: mocks.capabilities, browserClose: mocks.browserClose, browserListTabs: mocks.browserListTabs, + browserListDownloads: mocks.browserListDownloads, })) vi.mock("@/lib/transport", () => ({ getTransport: () => ({ subscribe: mocks.subscribe }), @@ -62,6 +76,10 @@ import { resetBrowserTabStoreForTests, setBrowserTabState, } from "@/lib/browser/browser-tab-store" +import { + getBrowserDownloads, + resetBrowserDownloadsForTests, +} from "@/lib/browser/browser-downloads-store" import { BrowserEventsBridge } from "./browser-events-bridge" async function flush() { @@ -80,9 +98,14 @@ describe("BrowserEventsBridge", () => { mocks.closeFileTab.mockClear() mocks.openBrowserTab.mockClear() mocks.browserClose.mockClear() + mocks.browserListDownloads.mockClear() resetBrowserTabStoreForTests() + resetBrowserDownloadsForTests() + }) + afterEach(() => { + resetBrowserTabStoreForTests() + resetBrowserDownloadsForTests() }) - afterEach(() => resetBrowserTabStoreForTests()) it("subscribes to the streams once the capabilities say a browser exists, after sweeping orphans", async () => { const { unmount } = render(<BrowserEventsBridge />) @@ -92,10 +115,22 @@ describe("BrowserEventsBridge", () => { expect(mocks.browserClose).toHaveBeenCalledWith("stale-2") expect([...mocks.handlers.keys()].sort()).toEqual([ "browser://closed", + "browser://download", "browser://open-request", "browser://popup", "browser://state", ]) + // Downloads already running when this document mounted are shown again. + expect(getBrowserDownloads().map((d) => d.id)).toEqual(["dl-1"]) + mocks.handlers.get("browser://download")!({ + id: "dl-2", + tabId: "t1", + url: "https://example.com/b.bin", + fileName: "b.bin", + path: "/Users/dev/Downloads/b.bin", + state: "started", + }) + expect(getBrowserDownloads().map((d) => d.id)).toEqual(["dl-2", "dl-1"]) mocks.handlers.get("browser://open-request")!({ url: "https://example.com/from-agent", @@ -200,6 +235,7 @@ describe("BrowserEventsBridge", () => { unmount() expect(mocks.unsubscribed.sort()).toEqual([ "browser://closed", + "browser://download", "browser://open-request", "browser://popup", "browser://state", @@ -215,6 +251,7 @@ describe("BrowserEventsBridge", () => { reasons: ["web"], isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, + downloadsDir: "/Users/dev/Downloads", }) render(<BrowserEventsBridge />) await flush() diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 2e86b21870..ad902521d3 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -6,8 +6,13 @@ import { useWorkspaceActions } from "@/contexts/workspace-context" import { browserCapabilities, browserClose, + browserListDownloads, browserListTabs, } from "@/lib/browser/browser-api" +import { + hydrateBrowserDownloads, + setBrowserDownload, +} from "@/lib/browser/browser-downloads-store" import { browserWorkspaceTabId, removeBrowserTabState, @@ -16,10 +21,12 @@ import { } from "@/lib/browser/browser-tab-store" import { BROWSER_CLOSED_EVENT, + BROWSER_DOWNLOAD_EVENT, BROWSER_OPEN_REQUEST_EVENT, BROWSER_POPUP_EVENT, BROWSER_STATE_EVENT, type BrowserClosedPayload, + type BrowserDownload, type BrowserOpenRequestPayload, type BrowserPopupPayload, type BrowserTabState, @@ -38,6 +45,7 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" * by the user, owner window gone) drops its tab record * - `browser://open-request` → the backend (an agent tool, a deep link, the * dev puppet) asks this window's workspace to open a URL + * - `browser://download` → the download bar of the tab that started it * * Only subscribes where a built-in browser exists; in web mode there is * nothing to hear. @@ -63,6 +71,13 @@ export function BrowserEventsBridge() { } catch { /* nothing to sweep */ } + // Downloads outlive the tabs that started them (and this document): a + // reload must not lose the record of a file that is still arriving. + try { + hydrateBrowserDownloads(await browserListDownloads()) + } catch { + /* no downloads to show */ + } if (cancelled) return const transport = getTransport() const subs = await Promise.all([ @@ -96,6 +111,12 @@ export function BrowserEventsBridge() { closeFileTab(tabId) } ), + transport.subscribe<BrowserDownload>( + BROWSER_DOWNLOAD_EVENT, + (download) => { + setBrowserDownload(download) + } + ), transport.subscribe<BrowserOpenRequestPayload>( BROWSER_OPEN_REQUEST_EVENT, (request) => { diff --git a/src/components/browser/browser-status-layer.test.tsx b/src/components/browser/browser-status-layer.test.tsx index 834ee90dab..920386633e 100644 --- a/src/components/browser/browser-status-layer.test.tsx +++ b/src/components/browser/browser-status-layer.test.tsx @@ -4,21 +4,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ actions: null as null | { openBrowserTab: ReturnType<typeof vi.fn> }, + openUrl: vi.fn(), + revealItemInDir: vi.fn(), })) vi.mock("@/contexts/workspace-context", () => ({ useOptionalWorkspaceActions: () => mocks.actions, })) vi.mock("@/lib/browser/browser-api", () => ({ browserReload: vi.fn() })) -vi.mock("@/lib/platform", () => ({ openUrl: vi.fn() })) +vi.mock("@/lib/platform", () => ({ + openUrl: mocks.openUrl, + revealItemInDir: mocks.revealItemInDir, +})) -import { BrowserErrorPage, BrowserNoticeBar } from "./browser-status-layer" +import { + BrowserDownloadBar, + BrowserErrorPage, + BrowserNoticeBar, +} from "./browser-status-layer" import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" import enMessages from "@/i18n/messages/en.json" import { resetBrowserTabStoreForTests, setBrowserTabNotice, } from "@/lib/browser/browser-tab-store" +import { + resetBrowserDownloadsForTests, + setBrowserDownload, +} from "@/lib/browser/browser-downloads-store" +import type { BrowserDownload } from "@/lib/browser/types" const tab = { id: "browser:abc", @@ -44,7 +58,10 @@ function renderBar() { beforeEach(() => { resetBrowserTabStoreForTests() + resetBrowserDownloadsForTests() mocks.actions = null + mocks.openUrl.mockClear() + mocks.revealItemInDir.mockClear() }) describe("BrowserNoticeBar", () => { @@ -135,3 +152,61 @@ describe("BrowserErrorPage", () => { ).not.toBeInTheDocument() }) }) + +describe("BrowserDownloadBar", () => { + function renderDownloads() { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserDownloadBar tab={tab} /> + </NextIntlClientProvider> + ) + } + + const download = (over: Partial<BrowserDownload> = {}): BrowserDownload => ({ + id: "dl-1", + tabId: "abc", + url: "https://example.com/a.bin", + fileName: "a.bin", + path: "/Users/dev/Downloads/a.bin", + state: "started", + ...over, + }) + + it("renders nothing without downloads", () => { + const { container } = renderDownloads() + expect(container).toBeEmptyDOMElement() + }) + + // Never "open": a file that just arrived from the web is revealed in the + // file manager, and running it stays the user's decision. + it("offers show-in-folder only once a download completed", () => { + renderDownloads() + act(() => setBrowserDownload(download())) + expect(screen.getByText("a.bin")).toBeInTheDocument() + expect(screen.getByText("Downloading…")).toBeInTheDocument() + expect(screen.queryByText("Show in folder")).not.toBeInTheDocument() + + act(() => setBrowserDownload(download({ state: "completed" }))) + expect(screen.getByText("Saved")).toBeInTheDocument() + fireEvent.click(screen.getByText("Show in folder")) + expect(mocks.revealItemInDir).toHaveBeenCalledWith( + "/Users/dev/Downloads/a.bin" + ) + }) + + it("shows a failed download and lets the row be dismissed", () => { + renderDownloads() + act(() => setBrowserDownload(download({ state: "failed" }))) + expect(screen.getByText("Failed")).toBeInTheDocument() + expect(screen.queryByText("Show in folder")).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })) + expect(screen.queryByText("a.bin")).not.toBeInTheDocument() + }) + + it("ignores downloads that belong to another tab", () => { + const { container } = renderDownloads() + act(() => setBrowserDownload(download({ tabId: "other" }))) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index 25ab5b5b72..d83d46ee17 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -1,11 +1,25 @@ "use client" -import { ExternalLink, RotateCw, ShieldAlert, X } from "lucide-react" +import { + AlertTriangle, + Check, + Download, + ExternalLink, + FolderOpen, + RotateCw, + ShieldAlert, + X, +} from "lucide-react" import { useTranslations } from "next-intl" import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" import { useOptionalWorkspaceActions } from "@/contexts/workspace-context" import { browserReload } from "@/lib/browser/browser-api" +import { + dismissAllBrowserDownloads, + dismissBrowserDownload, + useBrowserTabDownloads, +} from "@/lib/browser/browser-downloads-store" import { setBrowserTabNotice, useBrowserTabNotice, @@ -13,7 +27,7 @@ import { import { displayHostPort } from "@/lib/browser/browser-url" import type { BrowserErrorInfo, BrowserTabState } from "@/lib/browser/types" import { browserTabBackendId } from "@/lib/file-tab-id" -import { openUrl } from "@/lib/platform" +import { openUrl, revealItemInDir } from "@/lib/platform" /** Bars that sit OUTSIDE the native surface's rect (a native view paints over * any DOM placed on top of it): blocked popups, remote-egress banner. */ @@ -174,3 +188,65 @@ export function BrowserOwnedWindowCard({ </div> ) } + +/** + * Downloads of this tab, above the page. A download is never opened for the + * user — the bar offers "show in folder", which is what a file arriving from + * the web deserves; running it is their decision, in their file manager. + */ +export function BrowserDownloadBar({ tab }: { tab: BrowserWorkspaceTab }) { + const t = useTranslations("Browser.download") + const downloads = useBrowserTabDownloads(tab.id) + if (downloads.length === 0) return null + return ( + <div className="flex flex-col border-b border-border/60 bg-muted/40"> + {downloads.map((download) => ( + <div + key={download.id} + className="flex h-8 items-center gap-2 px-3 text-xs text-foreground" + > + {download.state === "completed" ? ( + <Check className="h-3.5 w-3.5 shrink-0 text-emerald-600" /> + ) : download.state === "failed" ? ( + <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" /> + ) : ( + <Download className="h-3.5 w-3.5 shrink-0 animate-pulse text-muted-foreground" /> + )} + <span className="min-w-0 flex-1 truncate" title={download.path}> + {download.fileName} + <span className="ml-2 text-muted-foreground"> + {download.state === "completed" + ? t("completed") + : download.state === "failed" + ? t("failed") + : t("started")} + </span> + </span> + {download.state === "completed" ? ( + <button + type="button" + className="flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 font-medium text-primary hover:bg-primary/8" + onClick={() => void revealItemInDir(download.path)} + > + <FolderOpen className="h-3.5 w-3.5" /> + {t("reveal")} + </button> + ) : null} + <button + type="button" + className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-primary/8" + title={t("dismiss")} + aria-label={t("dismiss")} + onClick={() => + downloads.length > 1 + ? dismissBrowserDownload(download.id) + : dismissAllBrowserDownloads() + } + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ))} + </div> + ) +} diff --git a/src/components/browser/browser-tab-view.tsx b/src/components/browser/browser-tab-view.tsx index 9f0b74ff9a..8398aa1377 100644 --- a/src/components/browser/browser-tab-view.tsx +++ b/src/components/browser/browser-tab-view.tsx @@ -6,6 +6,7 @@ import { useBrowserTabState } from "@/lib/browser/browser-tab-store" import { browserTabBackendId } from "@/lib/file-tab-id" import { + BrowserDownloadBar, BrowserErrorPage, BrowserNoticeBar, BrowserOwnedWindowCard, @@ -28,6 +29,7 @@ export function BrowserTabView({ tab }: { tab: BrowserWorkspaceTab }) { <div className="flex h-full min-h-0 flex-col"> <BrowserToolbar tab={tab} state={state} /> <BrowserNoticeBar tab={tab} state={state} /> + <BrowserDownloadBar tab={tab} /> <div className="relative min-h-0 flex-1"> {/* Always mounted so the native surface keeps its bounds; the DOM layers below only show when the surface is hidden (error) or diff --git a/src/components/browser/browser-tabs-persistence.test.tsx b/src/components/browser/browser-tabs-persistence.test.tsx index 5c22eae431..c8fc8a66fa 100644 --- a/src/components/browser/browser-tabs-persistence.test.tsx +++ b/src/components/browser/browser-tabs-persistence.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ reasons: [], isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, + downloadsDir: "/Users/dev/Downloads", }) ), restoreBrowserTabs: vi.fn(), @@ -154,6 +155,7 @@ describe("BrowserTabsPersistence", () => { reasons: [], isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, + downloadsDir: "/Users/dev/Downloads", }) await Promise.resolve() }) @@ -190,6 +192,7 @@ describe("BrowserTabsPersistence", () => { reasons: [], isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, + downloadsDir: "/Users/dev/Downloads", }) render(<BrowserTabsPersistence />) await act(async () => { diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index 7332d6db9d..e43d2ebccf 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -3,8 +3,8 @@ /** * Built-in browser settings: where links open by default (per source), whether * browser tabs get the web inspector, which native surface hosts them, whether - * background tabs are unloaded after a while, and a one-shot "clear browsing - * data". + * background tabs are unloaded after a while, where downloads land, and a + * one-shot "clear browsing data". * * Preferences live in localStorage (`browser-prefs.ts`): written immediately, * mirrored across windows through the storage event, so there is no Save @@ -17,6 +17,7 @@ import { useEffect, useState } from "react" import { useTranslations } from "next-intl" import { AppWindow, + Download, Eraser, Globe, Link2, @@ -119,6 +120,7 @@ export function BrowserSettingsSection() { const [confirmClear, setConfirmClear] = useState(false) const [clearing, setClearing] = useState(false) const [proxy, setProxy] = useState<BrowserProxyStatus | null>(null) + const [downloadsDir, setDownloadsDir] = useState<string | null>(null) // Fetched when the section opens (not once per app run): the answer follows // the proxy setting, which lives on another settings page. @@ -127,10 +129,14 @@ export function BrowserSettingsSection() { let cancelled = false browserCapabilitiesNow() .then((caps) => { - if (!cancelled) setProxy(caps.proxy) + if (cancelled) return + setProxy(caps.proxy) + setDownloadsDir(caps.downloadsDir || null) }) .catch(() => { - if (!cancelled) setProxy(null) + if (cancelled) return + setProxy(null) + setDownloadsDir(null) }) return () => { cancelled = true @@ -276,6 +282,11 @@ export function BrowserSettingsSection() { ))} </div> </SettingRow> + <SettingRow + icon={Download} + title={t("downloadsTitle")} + description={t("downloadsHint", { dir: downloadsDir ?? "…" })} + /> <SettingRow icon={Eraser} title={t("clearTitle")} diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx index 8d8bbb7056..d4a1202080 100644 --- a/src/hooks/use-open-url-target.test.tsx +++ b/src/hooks/use-open-url-target.test.tsx @@ -46,6 +46,7 @@ const AVAILABLE = { reasons: [], isolatedStorage: true, proxy: { url: null, applies: "live" as const, reason: null }, + downloadsDir: "/Users/dev/Downloads", } describe("useOpenUrlTarget", () => { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d66479e617..a7f4dddc25 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "غير متاح في هذا الإصدار من macOS (يلزم الإصدار 14 أو أحدث).", "proxyUnusable": "الوكيل المُعدّ ليس من نوع يمكن لعلامات تبويب المتصفح استخدامه (http وsocks5 فقط).", "suspendTitle": "إلغاء تحميل علامات التبويب في الخلفية", - "suspendHint": "يحرّر ذاكرة علامات تبويب المتصفح التي تبقى في الخلفية لمدة 30 دقيقة. يُعاد تحميلها عند العودة إليها؛ ويُفقد موضع التمرير والسجل." + "suspendHint": "يحرّر ذاكرة علامات تبويب المتصفح التي تبقى في الخلفية لمدة 30 دقيقة. يُعاد تحميلها عند العودة إليها؛ ويُفقد موضع التمرير والسجل.", + "downloadsTitle": "التنزيلات", + "downloadsHint": "تُحفظ الملفات التي تنزّلها الصفحة في {dir}. لا يُفتح أي ملف تلقائيًا، ولا يُستبدل ملف موجود أبدًا." }, "LogsSettings": { "loading": "جارٍ التحميل…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "فتح في مساحة العمل", "cannotOpen": "لا يمكن فتح هذا العنوان هنا." }, + "download": { + "started": "جارٍ التنزيل…", + "completed": "تم الحفظ", + "failed": "فشل", + "reveal": "إظهار في المجلد", + "dismiss": "إخفاء" + }, "toast": { "firstOpen": "تم الفتح في المتصفح المدمج", "firstOpenHint": "اضغط ⌘/Ctrl أثناء النقر على رابط لفتحه في متصفح النظام بدلًا من ذلك. يمكن تغيير الافتراضي من الإعدادات.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e14b7f01bb..8c5e914717 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "Auf dieser macOS-Version nicht verfügbar (14 oder neuer erforderlich).", "proxyUnusable": "Der konfigurierte Proxy ist kein Typ, den Browser-Tabs verwenden können (nur http und socks5).", "suspendTitle": "Hintergrund-Tabs entladen", - "suspendHint": "Gibt den Speicher von Browser-Tabs frei, die 30 Minuten im Hintergrund waren. Beim Zurückwechseln werden sie neu geladen; Scrollposition und Verlauf gehen verloren." + "suspendHint": "Gibt den Speicher von Browser-Tabs frei, die 30 Minuten im Hintergrund waren. Beim Zurückwechseln werden sie neu geladen; Scrollposition und Verlauf gehen verloren.", + "downloadsTitle": "Downloads", + "downloadsHint": "Von einer Seite geladene Dateien werden in {dir} gespeichert. Nichts wird automatisch geöffnet, und eine vorhandene Datei wird nie ersetzt." }, "LogsSettings": { "loading": "Wird geladen…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "Im Arbeitsbereich öffnen", "cannotOpen": "Diese Adresse kann hier nicht geöffnet werden." }, + "download": { + "started": "Wird geladen…", + "completed": "Gespeichert", + "failed": "Fehlgeschlagen", + "reveal": "Im Ordner zeigen", + "dismiss": "Ausblenden" + }, "toast": { "firstOpen": "Im integrierten Browser geöffnet", "firstOpenHint": "⌘/Strg+Klick auf einen Link öffnet ihn stattdessen im Systembrowser. Die Voreinstellung lässt sich in den Einstellungen ändern.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c7dec2e118..0f85d655be 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "Not available on this version of macOS (14 or later is needed).", "proxyUnusable": "The configured proxy is not one browser tabs can use (http and socks5 only).", "suspendTitle": "Unload background tabs", - "suspendHint": "Release the memory of browser tabs that stay in the background for 30 minutes. They load again when you switch back; scroll position and history are lost." + "suspendHint": "Release the memory of browser tabs that stay in the background for 30 minutes. They load again when you switch back; scroll position and history are lost.", + "downloadsTitle": "Downloads", + "downloadsHint": "Files a page downloads are saved to {dir}. Nothing is opened automatically, and an existing file is never replaced." }, "LogsSettings": { "loading": "Loading…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "Open in workspace", "cannotOpen": "This address can't be opened here." }, + "download": { + "started": "Downloading…", + "completed": "Saved", + "failed": "Failed", + "reveal": "Show in folder", + "dismiss": "Dismiss" + }, "toast": { "firstOpen": "Opened in the built-in browser", "firstOpenHint": "⌘/Ctrl-click a link to open it in your system browser instead. Change the default in Settings.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 91fabeeb2d..797edc3ebf 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "No disponible en esta versión de macOS (se necesita 14 o posterior).", "proxyUnusable": "El proxy configurado no es de un tipo que las pestañas del navegador puedan usar (solo http y socks5).", "suspendTitle": "Descargar pestañas en segundo plano", - "suspendHint": "Libera la memoria de las pestañas del navegador que llevan 30 minutos en segundo plano. Se vuelven a cargar al regresar; se pierden la posición de desplazamiento y el historial." + "suspendHint": "Libera la memoria de las pestañas del navegador que llevan 30 minutos en segundo plano. Se vuelven a cargar al regresar; se pierden la posición de desplazamiento y el historial.", + "downloadsTitle": "Descargas", + "downloadsHint": "Los archivos que descarga una página se guardan en {dir}. No se abre nada automáticamente y nunca se reemplaza un archivo existente." }, "LogsSettings": { "loading": "Cargando…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "Abrir en el espacio de trabajo", "cannotOpen": "Esta dirección no se puede abrir aquí." }, + "download": { + "started": "Descargando…", + "completed": "Guardado", + "failed": "Error", + "reveal": "Mostrar en la carpeta", + "dismiss": "Descartar" + }, "toast": { "firstOpen": "Abierto en el navegador integrado", "firstOpenHint": "Haz ⌘/Ctrl+clic en un enlace para abrirlo en el navegador del sistema. Cambia el valor predeterminado en Ajustes.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 53dafa54b1..454b17ffa7 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "Indisponible sur cette version de macOS (14 ou ultérieure requise).", "proxyUnusable": "Le proxy configuré n’est pas d’un type utilisable par les onglets du navigateur (http et socks5 uniquement).", "suspendTitle": "Décharger les onglets en arrière-plan", - "suspendHint": "Libère la mémoire des onglets du navigateur restés 30 minutes en arrière-plan. Ils se rechargent quand vous y revenez ; la position de défilement et l’historique sont perdus." + "suspendHint": "Libère la mémoire des onglets du navigateur restés 30 minutes en arrière-plan. Ils se rechargent quand vous y revenez ; la position de défilement et l’historique sont perdus.", + "downloadsTitle": "Téléchargements", + "downloadsHint": "Les fichiers téléchargés par une page sont enregistrés dans {dir}. Rien n’est ouvert automatiquement et aucun fichier existant n’est remplacé." }, "LogsSettings": { "loading": "Chargement…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "Ouvrir dans l'espace de travail", "cannotOpen": "Cette adresse ne peut pas être ouverte ici." }, + "download": { + "started": "Téléchargement…", + "completed": "Enregistré", + "failed": "Échec", + "reveal": "Afficher dans le dossier", + "dismiss": "Masquer" + }, "toast": { "firstOpen": "Ouvert dans le navigateur intégré", "firstOpenHint": "⌘/Ctrl+clic sur un lien pour l'ouvrir dans le navigateur système. Le choix par défaut se modifie dans les Réglages.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 793cf3c745..ba28cdb3a4 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "このバージョンの macOS では利用できません(macOS 14 以降が必要です)。", "proxyUnusable": "設定されたプロキシはブラウザタブで使用できない種類です(http と socks5 のみ対応)。", "suspendTitle": "バックグラウンドのタブを解放", - "suspendHint": "30 分間バックグラウンドにあるブラウザタブのメモリを解放します。戻ると再読み込みされ、スクロール位置と履歴は失われます。" + "suspendHint": "30 分間バックグラウンドにあるブラウザタブのメモリを解放します。戻ると再読み込みされ、スクロール位置と履歴は失われます。", + "downloadsTitle": "ダウンロード", + "downloadsHint": "ページからダウンロードしたファイルは {dir} に保存されます。自動的に開かれることはなく、既存のファイルを上書きしません。" }, "LogsSettings": { "loading": "読み込み中…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "ワークスペースで開く", "cannotOpen": "このアドレスはここでは開けません。" }, + "download": { + "started": "ダウンロード中…", + "completed": "保存しました", + "failed": "失敗しました", + "reveal": "フォルダに表示", + "dismiss": "閉じる" + }, "toast": { "firstOpen": "内蔵ブラウザで開きました", "firstOpenHint": "⌘/Ctrl を押しながらリンクをクリックするとシステムのブラウザで開きます。既定は設定で変更できます。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 2c43cb7620..c7ee65a334 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "이 macOS 버전에서는 사용할 수 없습니다(macOS 14 이상 필요).", "proxyUnusable": "설정된 프록시는 브라우저 탭에서 사용할 수 없는 종류입니다(http 및 socks5만 지원).", "suspendTitle": "백그라운드 탭 내려놓기", - "suspendHint": "30분 동안 백그라운드에 있는 브라우저 탭의 메모리를 해제합니다. 다시 전환하면 새로 불러오며, 스크롤 위치와 방문 기록은 사라집니다." + "suspendHint": "30분 동안 백그라운드에 있는 브라우저 탭의 메모리를 해제합니다. 다시 전환하면 새로 불러오며, 스크롤 위치와 방문 기록은 사라집니다.", + "downloadsTitle": "다운로드", + "downloadsHint": "페이지에서 내려받은 파일은 {dir}에 저장됩니다. 자동으로 열지 않으며 기존 파일을 덮어쓰지 않습니다." }, "LogsSettings": { "loading": "불러오는 중…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "작업 공간에서 열기", "cannotOpen": "이 주소는 여기에서 열 수 없습니다." }, + "download": { + "started": "내려받는 중…", + "completed": "저장됨", + "failed": "실패함", + "reveal": "폴더에서 보기", + "dismiss": "닫기" + }, "toast": { "firstOpen": "내장 브라우저에서 열었습니다", "firstOpenHint": "⌘/Ctrl을 누른 채 링크를 클릭하면 시스템 브라우저에서 열립니다. 기본값은 설정에서 바꿀 수 있습니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 888a6d6504..1aa3442cb5 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "Indisponível nesta versão do macOS (é necessária a 14 ou posterior).", "proxyUnusable": "O proxy configurado não é de um tipo que os separadores do navegador possam usar (apenas http e socks5).", "suspendTitle": "Descarregar abas em segundo plano", - "suspendHint": "Libera a memória das abas do navegador que ficam 30 minutos em segundo plano. Elas recarregam quando você volta; a posição de rolagem e o histórico são perdidos." + "suspendHint": "Libera a memória das abas do navegador que ficam 30 minutos em segundo plano. Elas recarregam quando você volta; a posição de rolagem e o histórico são perdidos.", + "downloadsTitle": "Downloads", + "downloadsHint": "Os arquivos baixados por uma página são salvos em {dir}. Nada é aberto automaticamente e um arquivo existente nunca é substituído." }, "LogsSettings": { "loading": "Carregando…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "Abrir no espaço de trabalho", "cannotOpen": "Este endereço não pode ser aberto aqui." }, + "download": { + "started": "Baixando…", + "completed": "Salvo", + "failed": "Falhou", + "reveal": "Mostrar na pasta", + "dismiss": "Dispensar" + }, "toast": { "firstOpen": "Aberto no navegador integrado", "firstOpenHint": "⌘/Ctrl+clique em um link para abri-lo no navegador do sistema. Altere o padrão em Configurações.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 09823baa27..845e3d89c0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "当前 macOS 版本不支持(需要 macOS 14 或更高)。", "proxyUnusable": "配置的代理不是浏览器标签页能使用的类型(仅支持 http 和 socks5)。", "suspendTitle": "卸载后台标签页", - "suspendHint": "释放在后台停留 30 分钟的浏览器标签页所占的内存。切回时会重新加载;滚动位置和历史记录会丢失。" + "suspendHint": "释放在后台停留 30 分钟的浏览器标签页所占的内存。切回时会重新加载;滚动位置和历史记录会丢失。", + "downloadsTitle": "下载", + "downloadsHint": "网页下载的文件保存在 {dir}。不会自动打开,也不会覆盖已有文件。" }, "LogsSettings": { "loading": "加载中…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "在工作区中打开", "cannotOpen": "无法在此打开该地址。" }, + "download": { + "started": "正在下载…", + "completed": "已保存", + "failed": "下载失败", + "reveal": "在文件夹中显示", + "dismiss": "忽略" + }, "toast": { "firstOpen": "已在内置浏览器中打开", "firstOpenHint": "按住 ⌘/Ctrl 点击链接可改用系统浏览器。默认方式可在设置中修改。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 4c22e1348f..d03a817c74 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4762,7 +4762,9 @@ "proxyUnsupported": "目前的 macOS 版本不支援(需要 macOS 14 或更新)。", "proxyUnusable": "設定的代理不是瀏覽器分頁能使用的類型(僅支援 http 與 socks5)。", "suspendTitle": "卸載背景分頁", - "suspendHint": "釋放在背景停留 30 分鐘的瀏覽器分頁所佔的記憶體。切回時會重新載入;捲動位置與歷史記錄會遺失。" + "suspendHint": "釋放在背景停留 30 分鐘的瀏覽器分頁所佔的記憶體。切回時會重新載入;捲動位置與歷史記錄會遺失。", + "downloadsTitle": "下載", + "downloadsHint": "網頁下載的檔案儲存於 {dir}。不會自動開啟,也不會覆蓋既有檔案。" }, "LogsSettings": { "loading": "載入中…", @@ -5838,6 +5840,13 @@ "openInWorkspace": "在工作區中開啟", "cannotOpen": "無法在此開啟此網址。" }, + "download": { + "started": "正在下載…", + "completed": "已儲存", + "failed": "下載失敗", + "reveal": "在資料夾中顯示", + "dismiss": "忽略" + }, "toast": { "firstOpen": "已在內建瀏覽器中開啟", "firstOpenHint": "按住 ⌘/Ctrl 點擊連結可改用系統瀏覽器。預設方式可在設定中修改。", diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index a26db2458c..bbe87aa6ac 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -8,6 +8,7 @@ import { getTransport, isDesktop } from "@/lib/transport" import type { Bounds, BrowserCapabilities, + BrowserDownload, BrowserTabState, SurfaceChoice, } from "./types" @@ -20,6 +21,7 @@ const UNAVAILABLE: BrowserCapabilities = { reasons: ["built-in browser needs the desktop runtime"], isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, + downloadsDir: "", } let capabilitiesPromise: Promise<BrowserCapabilities> | null = null @@ -164,3 +166,13 @@ export function browserListTabs(): Promise<BrowserTabState[]> { export function browserClearData(): Promise<void> { return getTransport().call<void>("browser_clear_data", {}) } + +/** Downloads this run started, oldest first. */ +export function browserListDownloads(): Promise<BrowserDownload[]> { + return getTransport().call<BrowserDownload[]>("browser_list_downloads", {}) +} + +/** Forget the records; the downloaded files stay where they are. */ +export function browserClearDownloads(): Promise<void> { + return getTransport().call<void>("browser_clear_downloads", {}) +} diff --git a/src/lib/browser/browser-downloads-store.test.ts b/src/lib/browser/browser-downloads-store.test.ts new file mode 100644 index 0000000000..4c3b6654ab --- /dev/null +++ b/src/lib/browser/browser-downloads-store.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { + dismissAllBrowserDownloads, + dismissBrowserDownload, + getBrowserDownloads, + hydrateBrowserDownloads, + resetBrowserDownloadsForTests, + setBrowserDownload, + useBrowserTabDownloads, +} from "./browser-downloads-store" +import type { BrowserDownload } from "./types" + +function download(over: Partial<BrowserDownload> = {}): BrowserDownload { + return { + id: "dl-1", + tabId: "abc", + url: "https://example.com/a.bin", + fileName: "a.bin", + path: "/Users/dev/Downloads/a.bin", + state: "started", + ...over, + } +} + +describe("browser downloads store", () => { + beforeEach(() => resetBrowserDownloadsForTests()) + afterEach(() => resetBrowserDownloadsForTests()) + + it("keeps records newest first and updates one in place", () => { + setBrowserDownload(download()) + setBrowserDownload(download({ id: "dl-2", fileName: "b.bin" })) + expect(getBrowserDownloads().map((d) => d.id)).toEqual(["dl-2", "dl-1"]) + + setBrowserDownload(download({ state: "completed" })) + expect(getBrowserDownloads().map((d) => d.id)).toEqual(["dl-2", "dl-1"]) + expect(getBrowserDownloads()[1].state).toBe("completed") + }) + + it("hydrates from the backend list, whose order is oldest first", () => { + hydrateBrowserDownloads([download({ id: "old" }), download({ id: "new" })]) + expect(getBrowserDownloads().map((d) => d.id)).toEqual(["new", "old"]) + }) + + // `useSyncExternalStore` compares snapshots by identity: a fresh array per + // read would re-render forever. + it("returns a stable list per tab until something changes", () => { + setBrowserDownload(download()) + setBrowserDownload(download({ id: "dl-2", tabId: "other" })) + const { result, rerender } = renderHook(() => + useBrowserTabDownloads("browser:abc") + ) + const first = result.current + expect(first.map((d) => d.id)).toEqual(["dl-1"]) + rerender() + expect(result.current).toBe(first) + + act(() => setBrowserDownload(download({ state: "completed" }))) + expect(result.current).not.toBe(first) + expect(result.current[0].state).toBe("completed") + }) + + it("hides dismissed records without touching the list", () => { + setBrowserDownload(download()) + setBrowserDownload(download({ id: "dl-2" })) + const { result } = renderHook(() => useBrowserTabDownloads("browser:abc")) + expect(result.current).toHaveLength(2) + + act(() => dismissBrowserDownload("dl-2")) + expect(result.current.map((d) => d.id)).toEqual(["dl-1"]) + // The record itself is still there; only the bar forgets it. + expect(getBrowserDownloads()).toHaveLength(2) + + act(() => dismissAllBrowserDownloads()) + expect(result.current).toHaveLength(0) + + // A new download after a "dismiss all" shows up again. + act(() => setBrowserDownload(download({ id: "dl-3" }))) + expect(result.current.map((d) => d.id)).toEqual(["dl-3"]) + }) + + it("shows a download only in the tab that started it", () => { + setBrowserDownload(download({ tabId: "abc" })) + setBrowserDownload(download({ id: "dl-2", tabId: "xyz" })) + const { result } = renderHook(() => useBrowserTabDownloads("browser:xyz")) + expect(result.current.map((d) => d.id)).toEqual(["dl-2"]) + }) +}) diff --git a/src/lib/browser/browser-downloads-store.ts b/src/lib/browser/browser-downloads-store.ts new file mode 100644 index 0000000000..e681636a0f --- /dev/null +++ b/src/lib/browser/browser-downloads-store.ts @@ -0,0 +1,128 @@ +// Downloads started by browser tabs, newest first. +// +// Kept out of the per-tab state store: a download outlives the tab that +// started it (and the tab may be closed while it runs), and the bar that +// shows it is per tab but the record is not. Hydrated once from +// `browser_list_downloads`, then kept current by `browser://download`. + +import { useSyncExternalStore } from "react" + +import { browserWorkspaceTabId } from "./browser-tab-store" +import type { BrowserDownload } from "./types" + +type Listener = () => void + +const listeners = new Set<Listener>() +// Newest first; the backend caps its own history, this mirrors it. +let downloads: BrowserDownload[] = [] +const dismissed = new Set<string>() + +function notify(): void { + for (const listener of [...listeners]) listener() +} + +export function subscribeBrowserDownloads(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** Replace or insert one record (the event carries the whole thing). */ +export function setBrowserDownload(download: BrowserDownload): void { + const index = downloads.findIndex((d) => d.id === download.id) + if (index >= 0) { + const previous = downloads[index] + if ( + previous.state === download.state && + previous.path === download.path && + previous.fileName === download.fileName + ) { + return + } + downloads = [...downloads] + downloads[index] = download + } else { + downloads = [download, ...downloads] + } + notify() +} + +/** Initial list from the backend (oldest first there, newest first here). */ +export function hydrateBrowserDownloads(list: BrowserDownload[]): void { + downloads = [...list].reverse() + notify() +} + +/** Hide one record from the bar without touching the file or the backend. */ +export function dismissBrowserDownload(id: string): void { + if (dismissed.has(id)) return + dismissed.add(id) + notify() +} + +/** Hide every record currently known (the bar's "clear"). */ +export function dismissAllBrowserDownloads(): void { + let changed = false + for (const download of downloads) { + if (!dismissed.has(download.id)) { + dismissed.add(download.id) + changed = true + } + } + if (changed) notify() +} + +export function getBrowserDownloads(): BrowserDownload[] { + return downloads +} + +// Memoised per workspace tab id so `useSyncExternalStore` sees a stable +// reference between changes (it compares snapshots by identity, and a fresh +// array every read would loop forever). +let cache = new Map<string, BrowserDownload[]>() +let cacheGeneration: BrowserDownload[] | null = null +let cacheDismissed = 0 + +const EMPTY: BrowserDownload[] = [] + +function visibleFor(workspaceTabId: string): BrowserDownload[] { + if (cacheGeneration !== downloads || cacheDismissed !== dismissed.size) { + cache = new Map() + cacheGeneration = downloads + cacheDismissed = dismissed.size + } + const hit = cache.get(workspaceTabId) + if (hit) return hit + const list = downloads.filter( + (d) => + !dismissed.has(d.id) && browserWorkspaceTabId(d.tabId) === workspaceTabId + ) + const value = list.length === 0 ? EMPTY : list + cache.set(workspaceTabId, value) + return value +} + +function getServerSnapshot(): BrowserDownload[] { + return EMPTY +} + +/** Downloads of one tab that the user has not dismissed, newest first. */ +export function useBrowserTabDownloads( + workspaceTabId: string | null +): BrowserDownload[] { + return useSyncExternalStore( + subscribeBrowserDownloads, + () => (workspaceTabId ? visibleFor(workspaceTabId) : EMPTY), + getServerSnapshot + ) +} + +export function resetBrowserDownloadsForTests(): void { + downloads = [] + dismissed.clear() + listeners.clear() + cache = new Map() + cacheGeneration = null + cacheDismissed = 0 +} diff --git a/src/lib/browser/types.test.ts b/src/lib/browser/types.test.ts index 4e2e4c62a5..93e3cdb99f 100644 --- a/src/lib/browser/types.test.ts +++ b/src/lib/browser/types.test.ts @@ -46,6 +46,7 @@ describe("browser wire types", () => { applies: "live", reason: null, }, + downloadsDir: "/Users/dev/Downloads", } satisfies BrowserCapabilities const popup = { presentation: "adopted", diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index 23315994ff..b4b924352a 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -75,6 +75,21 @@ export interface BrowserCapabilities { /** Browsing data lives apart from the app's own web storage. */ isolatedStorage: boolean proxy: BrowserProxyStatus + /** Absolute path a page's downloads land in. */ + downloadsDir: string +} + +export type BrowserDownloadState = "started" | "completed" | "failed" + +/** `browser://download`: one record, emitted when it starts and when it ends. */ +export interface BrowserDownload { + id: string + tabId: string + url: string + fileName: string + /** Absolute path the engine writes to; never overwrites an existing file. */ + path: string + state: BrowserDownloadState } export type SurfaceChoice = "auto" | "child" | "window" @@ -121,3 +136,4 @@ export const BROWSER_STATE_EVENT = "browser://state" export const BROWSER_CLOSED_EVENT = "browser://closed" export const BROWSER_POPUP_EVENT = "browser://popup" export const BROWSER_TELEMETRY_EVENT = "browser://telemetry" +export const BROWSER_DOWNLOAD_EVENT = "browser://download" From 2366060c71d58d11b9c37826dfca809036f6e095 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 10:16:14 +0800 Subject: [PATCH 19/79] fix(browser): a download must not leave an error page behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A navigation that turns into a download never commits a document, so the load watcher saw "the requested address never arrived" and painted the error page over the page the user was still looking at — found by downloading a file from a directory listing in the dev app. The tab now settles instead: the watcher is retired, loading and error are cleared, and the requested address goes back to the document the tab is showing. The download reports itself through its own bar. --- src-tauri/src/browser/downloads.rs | 3 ++ src-tauri/src/browser/hooks.rs | 81 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src-tauri/src/browser/downloads.rs b/src-tauri/src/browser/downloads.rs index 1593037de2..812d9888a6 100644 --- a/src-tauri/src/browser/downloads.rs +++ b/src-tauri/src/browser/downloads.rs @@ -226,6 +226,9 @@ pub fn requested(app: &AppHandle, tab_id: &str, url: &str, destination: &mut Pat if let Some(downloads) = app.try_state::<BrowserDownloads>() { downloads.push(download.clone()); } + // A click that turns into a download leaves the tab's navigation + // unfinished for ever; tell the tab so its load watcher stands down. + super::hooks::navigation_became_download(app, tab_id); events::emit_download(app, &download); true } diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index c7a888cc53..b136d50aa6 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -77,6 +77,15 @@ pub fn begin_load(app: &AppHandle, tab_id: &str) { } } +/// The tab's state once a navigation became a download: not loading, no +/// error, and back on the document it is showing (what it "asked for" is no +/// longer the file being fetched). +fn settle_after_download(state: &mut crate::browser::types::BrowserTabState) { + state.loading = false; + state.error = None; + state.requested_url = state.url.clone(); +} + const LOAD_POLL: Duration = Duration::from_millis(500); const LOAD_FINISH_GRACE: Duration = Duration::from_millis(300); @@ -149,6 +158,27 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { }); } +/// A navigation turned into a download. Nothing will ever commit for it, so +/// the load watcher has to stand down: without this it would see "the +/// requested address never arrived" and paint the error page over the +/// document the tab is still perfectly happily showing. The tab keeps that +/// document; the download reports itself through `browser://download`. +pub fn navigation_became_download(app: &AppHandle, tab_id: &str) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let state = registry.update(tab_id, |tab| { + // A newer `load_seq` retires the watcher armed for the navigation + // that turned out to be this download. + tab.load_seq += 1; + settle_after_download(&mut tab.state); + tab.state.clone() + }); + if let Some(state) = state { + events::emit_state(app, &state); + } +} + pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { let Some(registry) = app.try_state::<BrowserRegistry>() else { return; @@ -162,6 +192,57 @@ pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { #[cfg(test)] mod tests { use super::*; + use crate::browser::types::{BrowserTabState, ChannelKind, SurfaceKind}; + + fn state(url: &str, requested: &str) -> BrowserTabState { + BrowserTabState { + tab_id: "t1".into(), + owner_window: "main".into(), + surface: SurfaceKind::Child, + channel: ChannelKind::Native, + url: url.into(), + requested_url: requested.into(), + title: "Listing".into(), + favicon: None, + loading: true, + can_go_back: false, + can_go_forward: false, + origin: None, + zoom: 1.0, + error: Some(BrowserErrorInfo { + kind: BrowserErrorKind::Failed, + message: String::new(), + url: Some(requested.into()), + }), + remote_host: None, + opener_tab_id: None, + } + } + + /// Clicking a link that downloads leaves the navigation for ever + /// uncommitted. Without this the load watcher's "the requested address + /// never arrived" rule paints an error page over a perfectly good page. + #[test] + fn a_download_leaves_the_tab_on_the_page_it_is_showing() { + let mut s = state("http://127.0.0.1:8790/", "http://127.0.0.1:8790/a.bin"); + settle_after_download(&mut s); + assert!(!s.loading); + assert!(s.error.is_none()); + assert_eq!(s.requested_url, "http://127.0.0.1:8790/"); + assert_eq!(s.url, "http://127.0.0.1:8790/"); + assert_eq!(s.title, "Listing"); + } + + /// A tab opened straight on a download URL has no document at all; it + /// stays empty rather than claiming a failure. + #[test] + fn a_download_into_a_fresh_tab_settles_empty() { + let mut s = state("", "http://127.0.0.1:8790/a.bin"); + settle_after_download(&mut s); + assert!(!s.loading); + assert!(s.error.is_none()); + assert_eq!(s.requested_url, ""); + } #[test] fn origin_is_none_for_opaque_urls() { From 8e0244187b765a4fce61ff713b8ae5c37c8ae5a1 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 10:31:14 +0800 Subject: [PATCH 20/79] =?UTF-8?q?feat(browser):=20find=20in=20page=20with?= =?UTF-8?q?=20=E2=8C=98F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search is WebKit's own (`findString:withConfiguration:`), so the page cannot see it, style it or break it, and the highlight is the engine's — the bar only collects the query and reports whether the last step matched. No match counter: the API answers "did this step find something", and a made-up count would be worse than none. ⌘F reaches the bar from either side of the boundary. While the page has keyboard focus the app's DOM never sees the keystroke, so the injected helper reports it over the isolated-world channel as a `shortcut` message and the host forwards `browser://shortcut`; the set of shortcut names is closed on the host side, so whatever a page claims, only `find` can act. This is the one place the helper cancels an event — the same claim every browser makes on its own shortcut — and it leaves propagation alone. Embedded surfaces only; an owned window is a plain WebviewWindow whose WKWebView the host does not hold. --- src-tauri/Cargo.toml | 2 +- src-tauri/src/browser/channel.rs | 12 ++ src-tauri/src/browser/events.rs | 15 +- src-tauri/src/browser/shim/macos.rs | 49 +++++- src-tauri/src/browser/smoke.rs | 11 ++ src-tauri/src/browser/surface.rs | 22 +++ src-tauri/src/browser/surface_child.rs | 36 +++++ src-tauri/src/browser/types.rs | 12 ++ src-tauri/src/commands/browser.rs | 43 +++++ src-tauri/src/lib.rs | 1 + src/browser-injected/helper.js | 24 +++ .../browser/browser-events-bridge.test.tsx | 27 +++- .../browser/browser-events-bridge.tsx | 12 ++ .../browser/browser-find-bar.test.tsx | 108 +++++++++++++ src/components/browser/browser-find-bar.tsx | 151 ++++++++++++++++++ src/components/browser/browser-tab-view.tsx | 37 ++++- src/i18n/messages/ar.json | 7 + src/i18n/messages/de.json | 7 + src/i18n/messages/en.json | 7 + src/i18n/messages/es.json | 7 + src/i18n/messages/fr.json | 7 + src/i18n/messages/ja.json | 7 + src/i18n/messages/ko.json | 7 + src/i18n/messages/pt.json | 7 + src/i18n/messages/zh-CN.json | 7 + src/i18n/messages/zh-TW.json | 7 + src/lib/browser/browser-api.ts | 16 ++ src/lib/browser/browser-tab-store.ts | 26 +++ src/lib/browser/types.ts | 8 + 29 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 src/components/browser/browser-find-bar.test.tsx create mode 100644 src/components/browser/browser-find-bar.tsx diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c9fc14feca..04062350e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -182,7 +182,7 @@ mac-notification-sys = "0.6" objc2 = "0.6" block2 = "0.6" objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet", "NSProcessInfo", "NSUUID"] } -objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKNavigation", "WKWebsiteDataStore", "WKWebsiteDataRecord"] } +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKFindConfiguration", "WKFindResult", "WKNavigation", "WKWebsiteDataStore", "WKWebsiteDataRecord"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/browser/channel.rs b/src-tauri/src/browser/channel.rs index 4dc9871494..0165199849 100644 --- a/src-tauri/src/browser/channel.rs +++ b/src-tauri/src/browser/channel.rs @@ -111,6 +111,18 @@ pub fn handle_message(app: &AppHandle, tab_id: &str, raw: String, main_frame: bo events::emit_state(app, &state); } } + // A browser shortcut the page had focus for. The set is closed here, + // not in the page: whatever the helper claims, only a name from this + // list reaches the frontend, and it carries nothing else. + "shortcut" => { + if !(main_frame && envelope.top) { + return; + } + let name = envelope.payload.get("name").and_then(Value::as_str); + if name == Some("find") { + events::emit_shortcut(app, tab_id, "find"); + } + } "gesture" => { registry.push_gesture(tab_id, envelope.payload.clone()); emit_event( diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index 35f09b18d4..59c97ea171 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -7,8 +7,8 @@ use crate::web::event_bridge::{emit_event, EventEmitter}; use super::downloads::{BrowserDownload, DOWNLOAD_EVENT}; use super::types::{ - BrowserClosedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, - CLOSED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, STATE_EVENT, + BrowserClosedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserShortcutPayload, + BrowserTabState, CLOSED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, SHORTCUT_EVENT, STATE_EVENT, }; pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { @@ -34,6 +34,17 @@ pub fn emit_open_request(app: &AppHandle, payload: &BrowserOpenRequestPayload) { emit_event(&EventEmitter::Tauri(app.clone()), OPEN_REQUEST_EVENT, payload); } +pub fn emit_shortcut(app: &AppHandle, tab_id: &str, shortcut: &str) { + emit_event( + &EventEmitter::Tauri(app.clone()), + SHORTCUT_EVENT, + BrowserShortcutPayload { + tab_id: tab_id.to_string(), + shortcut: shortcut.to_string(), + }, + ); +} + pub fn emit_download(app: &AppHandle, download: &BrowserDownload) { emit_event(&EventEmitter::Tauri(app.clone()), DOWNLOAD_EVENT, download); } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 4ef48f7cb6..7433d3a073 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -8,6 +8,7 @@ use std::cell::RefCell; use std::collections::HashSet; +use std::ptr::NonNull; use block2::RcBlock; use objc2::rc::Retained; @@ -16,9 +17,9 @@ use objc2::{define_class, msg_send, sel, DeclaredClass, MainThreadMarker, MainTh use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; use objc2_foundation::{ns_string, NSArray, NSDate, NSDictionary, NSError, NSProcessInfo, NSString, NSUUID}; use objc2_web_kit::{ - WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKSnapshotConfiguration, - WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebViewConfiguration, - WKWebsiteDataRecord, WKWebsiteDataStore, + WKContentWorld, WKFindConfiguration, WKFindResult, WKScriptMessage, WKScriptMessageHandler, + WKSnapshotConfiguration, WKUserContentController, WKUserScript, WKUserScriptInjectionTime, + WKWebViewConfiguration, WKWebsiteDataRecord, WKWebsiteDataStore, }; use tauri_runtime_wry::wry::{self, WebViewExtMacOS}; @@ -230,6 +231,48 @@ pub fn snapshot_png( Ok(()) } +/// Highlight the next (or previous) occurrence of `query` in the page, the +/// way ⌘F does in Safari: WebKit owns the search and the selection, so this +/// never touches the DOM and cannot be observed or broken by the page. +/// Wraps around, case-insensitive — the defaults a find bar is expected to +/// have. `callback` gets whether anything matched. +pub fn find_string( + webview: &wry::WebView, + query: &str, + forward: bool, + callback: impl Fn(bool) + Send + 'static, +) -> Result<(), String> { + let mtm = mtm()?; + let wk = webview.webview(); + let block = RcBlock::<dyn Fn(NonNull<WKFindResult>)>::new(move |result: NonNull<WKFindResult>| { + // SAFETY: WebKit hands us a live result for the duration of the call. + callback(unsafe { result.as_ref().matchFound() }); + }); + // SAFETY: main thread, live webview. + unsafe { + let configuration = WKFindConfiguration::new(mtm); + configuration.setBackwards(!forward); + configuration.setCaseSensitive(false); + configuration.setWraps(true); + wk.findString_withConfiguration_completionHandler( + &NSString::from_str(query), + Some(&configuration), + &block, + ); + } + Ok(()) +} + +/// Drop the find highlight. WebKit has no "stop finding" call; clearing the +/// selection (in the isolated world, on the shared DOM) is what removes it. +pub fn clear_find(webview: &wry::WebView) -> Result<(), String> { + eval_in_world( + webview, + "(function(){try{getSelection().removeAllRanges()}catch(e){}return true})()", + |_| {}, + ) +} + pub fn go_back(webview: &wry::WebView) { // SAFETY: main thread, live webview. unsafe { diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 197afa92fc..f115cebe85 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -398,6 +398,17 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .map_err(err_string)?; Ok(Value::Null) } + "browser_find" => { + let found = browser_commands::find_core( + ®istry, + &str_arg(cmd, "tab_id")?, + &str_arg(cmd, "query").unwrap_or_default(), + cmd.get("forward").and_then(Value::as_bool).unwrap_or(true), + ) + .await + .map_err(err_string)?; + Ok(json!(found)) + } // Download records this run produced, oldest first. "browser_downloads" => Ok(serde_json::to_value( app.state::<crate::browser::BrowserDownloads>().list(), diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index 0821b9e676..a3e7e91391 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -108,6 +108,28 @@ impl BrowserSurface { window: |w| Ok(w.eval_with_callback(js, callback)?)) } + /// Find in page. Only embedded surfaces have it: an owned window is a + /// plain `WebviewWindow`, whose `WKWebView` the host does not hold. + pub fn find( + &self, + query: &str, + forward: bool, + callback: impl Fn(bool) + Send + 'static, + ) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.find(query, forward, callback)?), + window: |_w| { + let _ = (query, forward, callback); + Err(SurfaceError("find in page needs an embedded surface".into())) + }) + } + + pub fn clear_find(&self) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.clear_find()?), + window: |_w| Err(SurfaceError("find in page needs an embedded surface".into()))) + } + pub fn hide(&self) -> Result<(), SurfaceError> { per_surface!(self, child: |c| Ok(c.set_visible(false)?), window: |w| Ok(w.hide()?)) } diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 562794bc86..64963c470b 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -293,6 +293,42 @@ impl ChildHandle { } } + /// Highlight the next / previous match of `query`; the callback gets + /// whether anything matched. + pub fn find( + &self, + query: &str, + forward: bool, + callback: impl Fn(bool) + Send + 'static, + ) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + let query = query.to_string(); + self.with(move |wv| shim::find_string(wv, &query, forward, callback))? + .map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (query, forward, callback); + Err(ChildError::Op( + "find in page is not implemented on this platform yet".into(), + )) + } + } + + pub fn clear_find(&self) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(shim::clear_find)?.map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + Err(ChildError::Op( + "find in page is not implemented on this platform yet".into(), + )) + } + } + pub fn go_back(&self) -> Result<(), ChildError> { #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index c308ba679c..223ddff8bd 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -122,6 +122,18 @@ pub const POPUP_EVENT: &str = "browser://popup"; /// deep links, the dev puppet). The frontend owns tab records, so a backend /// side cannot create one directly. pub const OPEN_REQUEST_EVENT: &str = "browser://open-request"; +/// A browser shortcut the PAGE swallowed first (the page has keyboard focus, +/// so the app's own DOM never sees the keystroke). Only the fixed set below +/// is forwarded; the payload carries no page data. +pub const SHORTCUT_EVENT: &str = "browser://shortcut"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserShortcutPayload { + pub tab_id: String, + /// One of a closed set the host recognises (`find` today). + pub shortcut: String, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index e39206e4b5..c36fbbf289 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -445,6 +445,39 @@ pub fn reload_core( Ok(()) } +/// Find in page. Returns whether the engine highlighted a match; an empty +/// query is the "close the find bar" case and only clears the highlight. +/// The search itself is WebKit's, so the page can neither see it nor break it. +pub async fn find_core( + registry: &BrowserRegistry, + tab_id: &str, + query: &str, + forward: bool, +) -> Result<bool, AppCommandError> { + let surface = surface_of(registry, tab_id)?; + if query.is_empty() { + surface + .clear_find() + .map_err(|e| window_err("Failed to clear the page search", e))?; + return Ok(false); + } + let (tx, rx) = tokio::sync::oneshot::channel::<bool>(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + surface + .find(query, forward, move |found| { + if let Some(tx) = tx.lock().unwrap_or_else(|p| p.into_inner()).take() { + let _ = tx.send(found); + } + }) + .map_err(|e| window_err("Failed to search the page", e))?; + // A search that never answers must not hang the caller; "no match" is the + // honest thing to show then. + match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await { + Ok(Ok(found)) => Ok(found), + _ => Ok(false), + } +} + pub fn go_back_core(registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { surface_of(registry, tab_id)? .go_back() @@ -597,6 +630,16 @@ pub async fn browser_stop( stop_core(&app, ®istry, &tab_id) } +#[tauri::command] +pub async fn browser_find( + registry: State<'_, BrowserRegistry>, + tab_id: String, + query: String, + forward: bool, +) -> Result<bool, AppCommandError> { + find_core(®istry, &tab_id, &query, forward).await +} + #[tauri::command] pub async fn browser_get_state( registry: State<'_, BrowserRegistry>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ddeb765ce..3a3eae7a54 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1207,6 +1207,7 @@ mod tauri_app { browser_commands::browser_get_state, browser_commands::browser_list_tabs, browser_commands::browser_clear_data, + browser_commands::browser_find, browser_commands::browser_list_downloads, browser_commands::browser_clear_downloads, conversations::list_conversations, diff --git a/src/browser-injected/helper.js b/src/browser-injected/helper.js index 0ac53f3c53..57ce71e2fa 100644 --- a/src/browser-injected/helper.js +++ b/src/browser-injected/helper.js @@ -229,6 +229,30 @@ true ) + // ---- browser shortcuts ------------------------------------------------- + // ⌘F / Ctrl-F belongs to the browser, not to the page: while the webview + // has keyboard focus the app's own DOM never sees the keystroke, so the + // find bar could not be opened at all. This is the ONE place the helper + // cancels an event — matching what every browser does with its own + // shortcut. Propagation is left alone, so a page listener that wants to + // know still hears it. + window.addEventListener( + "keydown", + function (event) { + if (!trusted(event)) return + if (event.repeat) return + var key = String(event.key || "").toLowerCase() + if (key !== "f") return + if (event.altKey) return + // ⌘ on macOS, Ctrl elsewhere; the host is the one that knows which, so + // report either and let it decide nothing — both are "find" here. + if (!event.metaKey && !event.ctrlKey) return + event.preventDefault() + post("shortcut", { name: "find" }) + }, + true + ) + post("hello", { href: String(location.href), readyState: String(document.readyState), diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 3914d02622..d193ea72ee 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -1,4 +1,4 @@ -import { act, render } from "@testing-library/react" +import { act, render, renderHook } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { BrowserCapabilities } from "@/lib/browser/types" @@ -75,6 +75,7 @@ import { getBrowserTabState, resetBrowserTabStoreForTests, setBrowserTabState, + useBrowserFindRequest, } from "@/lib/browser/browser-tab-store" import { getBrowserDownloads, @@ -82,6 +83,14 @@ import { } from "@/lib/browser/browser-downloads-store" import { BrowserEventsBridge } from "./browser-events-bridge" +/** The store's find counter, read the way a component would. */ +function findRequestOf(workspaceTabId: string): number { + const view = renderHook(() => useBrowserFindRequest(workspaceTabId)) + const seen = view.result.current + view.unmount() + return seen +} + async function flush() { await act(async () => { await Promise.resolve() @@ -118,6 +127,7 @@ describe("BrowserEventsBridge", () => { "browser://download", "browser://open-request", "browser://popup", + "browser://shortcut", "browser://state", ]) // Downloads already running when this document mounted are shown again. @@ -132,6 +142,20 @@ describe("BrowserEventsBridge", () => { }) expect(getBrowserDownloads().map((d) => d.id)).toEqual(["dl-2", "dl-1"]) + // ⌘F inside the page reaches the tab's find bar; anything else the page + // claims is dropped by the host, and an unknown name changes nothing. + expect(findRequestOf("browser:abc")).toBe(0) + mocks.handlers.get("browser://shortcut")!({ + tabId: "abc", + shortcut: "find", + }) + expect(findRequestOf("browser:abc")).toBe(1) + mocks.handlers.get("browser://shortcut")!({ + tabId: "abc", + shortcut: "quit", + }) + expect(findRequestOf("browser:abc")).toBe(1) + mocks.handlers.get("browser://open-request")!({ url: "https://example.com/from-agent", source: "agent", @@ -238,6 +262,7 @@ describe("BrowserEventsBridge", () => { "browser://download", "browser://open-request", "browser://popup", + "browser://shortcut", "browser://state", ]) }) diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index ad902521d3..966f1b39e1 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -16,6 +16,7 @@ import { import { browserWorkspaceTabId, removeBrowserTabState, + requestBrowserFind, setBrowserTabNotice, setBrowserTabState, } from "@/lib/browser/browser-tab-store" @@ -24,11 +25,13 @@ import { BROWSER_DOWNLOAD_EVENT, BROWSER_OPEN_REQUEST_EVENT, BROWSER_POPUP_EVENT, + BROWSER_SHORTCUT_EVENT, BROWSER_STATE_EVENT, type BrowserClosedPayload, type BrowserDownload, type BrowserOpenRequestPayload, type BrowserPopupPayload, + type BrowserShortcutPayload, type BrowserTabState, } from "@/lib/browser/types" import { getTransport } from "@/lib/transport" @@ -46,6 +49,7 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" * - `browser://open-request` → the backend (an agent tool, a deep link, the * dev puppet) asks this window's workspace to open a URL * - `browser://download` → the download bar of the tab that started it + * - `browser://shortcut` → a browser shortcut the page had focus for (⌘F) * * Only subscribes where a built-in browser exists; in web mode there is * nothing to hear. @@ -111,6 +115,14 @@ export function BrowserEventsBridge() { closeFileTab(tabId) } ), + transport.subscribe<BrowserShortcutPayload>( + BROWSER_SHORTCUT_EVENT, + (payload) => { + if (payload.shortcut === "find") { + requestBrowserFind(browserWorkspaceTabId(payload.tabId)) + } + } + ), transport.subscribe<BrowserDownload>( BROWSER_DOWNLOAD_EVENT, (download) => { diff --git a/src/components/browser/browser-find-bar.test.tsx b/src/components/browser/browser-find-bar.test.tsx new file mode 100644 index 0000000000..e433c22ea2 --- /dev/null +++ b/src/components/browser/browser-find-bar.test.tsx @@ -0,0 +1,108 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ browserFind: vi.fn() })) +vi.mock("@/lib/browser/browser-api", () => ({ browserFind: mocks.browserFind })) + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import enMessages from "@/i18n/messages/en.json" +import { BrowserFindBar } from "./browser-find-bar" + +const tab = { + id: "browser:abc", + kind: "browser", + folderId: null, + title: "example.com", + description: null, + path: null, + language: "browser", + content: "", + loading: false, + readonly: true, + browser: { initialUrl: "https://example.com/", openerTabId: null }, +} as unknown as BrowserWorkspaceTab + +function renderBar(open = true, onClose = vi.fn()) { + const view = render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserFindBar tab={tab} open={open} onClose={onClose} /> + </NextIntlClientProvider> + ) + return { ...view, onClose } +} + +beforeEach(() => { + mocks.browserFind.mockReset() + mocks.browserFind.mockResolvedValue(true) +}) + +describe("BrowserFindBar", () => { + it("renders nothing while closed", () => { + const { container } = renderBar(false) + expect(container).toBeEmptyDOMElement() + }) + + it("searches as the query is typed and steps with Enter", async () => { + renderBar() + const input = screen.getByLabelText("Find in page") + await act(async () => { + fireEvent.change(input, { target: { value: "needle" } }) + }) + expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "needle", true) + + // Enter walks forward, Shift+Enter backward — the engine owns the cursor. + await act(async () => { + fireEvent.keyDown(input, { key: "Enter" }) + }) + expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "needle", true) + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: true }) + }) + expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "needle", false) + + await act(async () => { + fireEvent.click(screen.getByLabelText("Previous match")) + }) + expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "needle", false) + }) + + it("says so when nothing matched", async () => { + mocks.browserFind.mockResolvedValue(false) + renderBar() + await act(async () => { + fireEvent.change(screen.getByLabelText("Find in page"), { + target: { value: "zzz" }, + }) + }) + expect(screen.getByText("No matches")).toBeInTheDocument() + }) + + it("clears the query without searching for an empty string", async () => { + renderBar() + const input = screen.getByLabelText("Find in page") + await act(async () => { + fireEvent.change(input, { target: { value: "a" } }) + fireEvent.change(input, { target: { value: "" } }) + }) + // An empty query is the engine's "drop the highlight", not a search. + expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "", true) + }) + + it("closes on Escape and drops the highlight when it goes away", async () => { + const onClose = vi.fn() + const { rerender } = renderBar(true, onClose) + fireEvent.keyDown(screen.getByLabelText("Find in page"), { key: "Escape" }) + expect(onClose).toHaveBeenCalled() + + mocks.browserFind.mockClear() + await act(async () => { + rerender( + <NextIntlClientProvider locale="en" messages={enMessages}> + <BrowserFindBar tab={tab} open={false} onClose={onClose} /> + </NextIntlClientProvider> + ) + }) + expect(mocks.browserFind).toHaveBeenCalledWith("abc", "", true) + }) +}) diff --git a/src/components/browser/browser-find-bar.tsx b/src/components/browser/browser-find-bar.tsx new file mode 100644 index 0000000000..456fa2a402 --- /dev/null +++ b/src/components/browser/browser-find-bar.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { ChevronDown, ChevronUp, X } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { browserFind } from "@/lib/browser/browser-api" +import { browserTabBackendId } from "@/lib/file-tab-id" +import { cn } from "@/lib/utils" + +const ICON_BTN = + "flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-primary/8 hover:text-foreground disabled:pointer-events-none disabled:opacity-40" + +/** + * ⌘F in a browser tab. The search itself is the engine's (WebKit's + * `findString:`), so the page cannot see it, style it, or break it — this bar + * only collects the query and reports whether the last step matched. + * + * It sits ABOVE the native surface in the flex column, like the toolbar: a + * native view paints over any DOM placed on top of it, so an overlay would be + * invisible. + * + * There is no match counter: WebKit's API answers "did this step find + * something", not "how many are there", and a made-up count is worse than + * none. + */ +export function BrowserFindBar({ + tab, + open, + onClose, +}: { + tab: BrowserWorkspaceTab + open: boolean + onClose: () => void +}) { + const t = useTranslations("Browser.find") + const backendId = browserTabBackendId(tab.id) + const [query, setQuery] = useState("") + const [missing, setMissing] = useState(false) + const inputRef = useRef<HTMLInputElement | null>(null) + + const step = useCallback( + (text: string, forward: boolean) => { + if (!backendId) return + if (!text) { + setMissing(false) + void browserFind(backendId, "", true).catch(() => {}) + return + } + void browserFind(backendId, text, forward) + .then((found) => setMissing(!found)) + .catch(() => setMissing(false)) + }, + [backendId] + ) + + // "No matches" belongs to the search that produced it: a bar that opens + // again starts clean. Adjusted during render on the prop transition (the + // state-from-previous-render pattern) rather than in an effect, so it never + // paints stale. + const [wasOpen, setWasOpen] = useState(open) + if (wasOpen !== open) { + setWasOpen(open) + setMissing(false) + } + + // Opening (or re-pressing ⌘F) selects what is there, the way a browser's + // find bar does, so a second search replaces the first by typing. + useEffect(() => { + if (!open) return + inputRef.current?.focus() + inputRef.current?.select() + }, [open]) + + // Closing drops the engine's highlight; leaving it behind would look like + // a page selection the user cannot get rid of. + useEffect(() => { + if (open || !backendId) return + void browserFind(backendId, "", true).catch(() => {}) + }, [open, backendId]) + + if (!open) return null + + return ( + <div className="flex h-9 shrink-0 items-center gap-1 border-b border-border/60 bg-muted/40 px-1.5"> + <input + ref={inputRef} + value={query} + onChange={(event) => { + const text = event.target.value + setQuery(text) + step(text, true) + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + step(query, !event.shiftKey) + } else if (event.key === "Escape") { + event.preventDefault() + onClose() + } + }} + spellCheck={false} + autoComplete="off" + placeholder={t("placeholder")} + aria-label={t("placeholder")} + className={cn( + "mx-1 h-7 min-w-0 flex-1 rounded-md border bg-background px-2.5 text-xs text-foreground outline-none", + missing + ? "border-destructive/60 text-destructive" + : "border-transparent focus:border-ring/50 focus:ring-2 focus:ring-ring/20" + )} + /> + {missing ? ( + <span className="shrink-0 px-1 text-xs text-muted-foreground"> + {t("noMatches")} + </span> + ) : null} + <button + type="button" + className={ICON_BTN} + title={t("previous")} + aria-label={t("previous")} + disabled={!query} + onClick={() => step(query, false)} + > + <ChevronUp className="h-4 w-4" /> + </button> + <button + type="button" + className={ICON_BTN} + title={t("next")} + aria-label={t("next")} + disabled={!query} + onClick={() => step(query, true)} + > + <ChevronDown className="h-4 w-4" /> + </button> + <button + type="button" + className={ICON_BTN} + title={t("close")} + aria-label={t("close")} + onClick={onClose} + > + <X className="h-4 w-4" /> + </button> + </div> + ) +} diff --git a/src/components/browser/browser-tab-view.tsx b/src/components/browser/browser-tab-view.tsx index 8398aa1377..e093f7c8fd 100644 --- a/src/components/browser/browser-tab-view.tsx +++ b/src/components/browser/browser-tab-view.tsx @@ -1,10 +1,16 @@ "use client" +import { useState, type KeyboardEvent } from "react" + import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" import { browserSetVisible } from "@/lib/browser/browser-api" -import { useBrowserTabState } from "@/lib/browser/browser-tab-store" +import { + useBrowserFindRequest, + useBrowserTabState, +} from "@/lib/browser/browser-tab-store" import { browserTabBackendId } from "@/lib/file-tab-id" +import { BrowserFindBar } from "./browser-find-bar" import { BrowserDownloadBar, BrowserErrorPage, @@ -21,13 +27,40 @@ import { BrowserToolbar } from "./browser-toolbar" export function BrowserTabView({ tab }: { tab: BrowserWorkspaceTab }) { const state = useBrowserTabState(tab.id) const backendId = browserTabBackendId(tab.id) + const [findOpen, setFindOpen] = useState(false) + // ⌘F pressed while the PAGE had keyboard focus: the app's DOM never sees + // that keystroke, so the host relays it and the store counts it. Read as a + // counter, not a flag, so pressing it again on an open bar still counts — + // and applied during render (not in an effect) so the bar is there in the + // same paint. + const findRequest = useBrowserFindRequest(tab.id) + const [seenFindRequest, setSeenFindRequest] = useState(findRequest) + if (seenFindRequest !== findRequest) { + setSeenFindRequest(findRequest) + setFindOpen(true) + } + + // ⌘F pressed while the focus is in this view's own DOM (address bar, find + // bar). Bound to the container rather than the window so the shortcut only + // belongs to the browser when the browser is what the user is in. + const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { + if (event.altKey || event.key.toLowerCase() !== "f") return + if (!event.metaKey && !event.ctrlKey) return + event.preventDefault() + setFindOpen(true) + } const url = state?.url || state?.requestedUrl || tab.browser.initialUrl const error = state?.error ?? null const ownedWindow = state?.surface === "window" return ( - <div className="flex h-full min-h-0 flex-col"> + <div className="flex h-full min-h-0 flex-col" onKeyDown={onKeyDown}> <BrowserToolbar tab={tab} state={state} /> + <BrowserFindBar + tab={tab} + open={findOpen && !ownedWindow} + onClose={() => setFindOpen(false)} + /> <BrowserNoticeBar tab={tab} state={state} /> <BrowserDownloadBar tab={tab} /> <div className="relative min-h-0 flex-1"> diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a7f4dddc25..2cdcadb910 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5847,6 +5847,13 @@ "reveal": "إظهار في المجلد", "dismiss": "إخفاء" }, + "find": { + "placeholder": "البحث في الصفحة", + "next": "التالي", + "previous": "السابق", + "noMatches": "لا توجد نتائج", + "close": "إغلاق" + }, "toast": { "firstOpen": "تم الفتح في المتصفح المدمج", "firstOpenHint": "اضغط ⌘/Ctrl أثناء النقر على رابط لفتحه في متصفح النظام بدلًا من ذلك. يمكن تغيير الافتراضي من الإعدادات.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8c5e914717..0218ae128c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5847,6 +5847,13 @@ "reveal": "Im Ordner zeigen", "dismiss": "Ausblenden" }, + "find": { + "placeholder": "Auf Seite suchen", + "next": "Weitersuchen", + "previous": "Rückwärts suchen", + "noMatches": "Keine Treffer", + "close": "Schließen" + }, "toast": { "firstOpen": "Im integrierten Browser geöffnet", "firstOpenHint": "⌘/Strg+Klick auf einen Link öffnet ihn stattdessen im Systembrowser. Die Voreinstellung lässt sich in den Einstellungen ändern.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0f85d655be..ab3f8745f3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5847,6 +5847,13 @@ "reveal": "Show in folder", "dismiss": "Dismiss" }, + "find": { + "placeholder": "Find in page", + "next": "Next match", + "previous": "Previous match", + "noMatches": "No matches", + "close": "Close" + }, "toast": { "firstOpen": "Opened in the built-in browser", "firstOpenHint": "⌘/Ctrl-click a link to open it in your system browser instead. Change the default in Settings.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 797edc3ebf..b4aa5f9cac 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5847,6 +5847,13 @@ "reveal": "Mostrar en la carpeta", "dismiss": "Descartar" }, + "find": { + "placeholder": "Buscar en la página", + "next": "Siguiente", + "previous": "Anterior", + "noMatches": "Sin coincidencias", + "close": "Cerrar" + }, "toast": { "firstOpen": "Abierto en el navegador integrado", "firstOpenHint": "Haz ⌘/Ctrl+clic en un enlace para abrirlo en el navegador del sistema. Cambia el valor predeterminado en Ajustes.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 454b17ffa7..e446e8bdc7 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5847,6 +5847,13 @@ "reveal": "Afficher dans le dossier", "dismiss": "Masquer" }, + "find": { + "placeholder": "Rechercher dans la page", + "next": "Suivant", + "previous": "Précédent", + "noMatches": "Aucun résultat", + "close": "Fermer" + }, "toast": { "firstOpen": "Ouvert dans le navigateur intégré", "firstOpenHint": "⌘/Ctrl+clic sur un lien pour l'ouvrir dans le navigateur système. Le choix par défaut se modifie dans les Réglages.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ba28cdb3a4..66a6452f74 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5847,6 +5847,13 @@ "reveal": "フォルダに表示", "dismiss": "閉じる" }, + "find": { + "placeholder": "ページ内を検索", + "next": "次を検索", + "previous": "前を検索", + "noMatches": "一致なし", + "close": "閉じる" + }, "toast": { "firstOpen": "内蔵ブラウザで開きました", "firstOpenHint": "⌘/Ctrl を押しながらリンクをクリックするとシステムのブラウザで開きます。既定は設定で変更できます。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index c7ee65a334..8f1a6a7ff6 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5847,6 +5847,13 @@ "reveal": "폴더에서 보기", "dismiss": "닫기" }, + "find": { + "placeholder": "페이지에서 찾기", + "next": "다음 찾기", + "previous": "이전 찾기", + "noMatches": "일치 항목 없음", + "close": "닫기" + }, "toast": { "firstOpen": "내장 브라우저에서 열었습니다", "firstOpenHint": "⌘/Ctrl을 누른 채 링크를 클릭하면 시스템 브라우저에서 열립니다. 기본값은 설정에서 바꿀 수 있습니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1aa3442cb5..59c81dcb3c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5847,6 +5847,13 @@ "reveal": "Mostrar na pasta", "dismiss": "Dispensar" }, + "find": { + "placeholder": "Localizar na página", + "next": "Próxima", + "previous": "Anterior", + "noMatches": "Nenhum resultado", + "close": "Fechar" + }, "toast": { "firstOpen": "Aberto no navegador integrado", "firstOpenHint": "⌘/Ctrl+clique em um link para abri-lo no navegador do sistema. Altere o padrão em Configurações.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 845e3d89c0..f04166b0c4 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5847,6 +5847,13 @@ "reveal": "在文件夹中显示", "dismiss": "忽略" }, + "find": { + "placeholder": "在页面中查找", + "next": "下一个", + "previous": "上一个", + "noMatches": "无匹配", + "close": "关闭" + }, "toast": { "firstOpen": "已在内置浏览器中打开", "firstOpenHint": "按住 ⌘/Ctrl 点击链接可改用系统浏览器。默认方式可在设置中修改。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d03a817c74..ec72e127e6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5847,6 +5847,13 @@ "reveal": "在資料夾中顯示", "dismiss": "忽略" }, + "find": { + "placeholder": "在頁面中尋找", + "next": "下一個", + "previous": "上一個", + "noMatches": "無相符項", + "close": "關閉" + }, "toast": { "firstOpen": "已在內建瀏覽器中開啟", "firstOpenHint": "按住 ⌘/Ctrl 點擊連結可改用系統瀏覽器。預設方式可在設定中修改。", diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index bbe87aa6ac..970a25e197 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -167,6 +167,22 @@ export function browserClearData(): Promise<void> { return getTransport().call<void>("browser_clear_data", {}) } +/** + * Highlight the next (or previous) match of `query` in the page and answer + * whether anything matched. An empty query clears the highlight. + */ +export function browserFind( + tabId: string, + query: string, + forward = true +): Promise<boolean> { + return getTransport().call<boolean>("browser_find", { + tabId, + query, + forward, + }) +} + /** Downloads this run started, oldest first. */ export function browserListDownloads(): Promise<BrowserDownload[]> { return getTransport().call<BrowserDownload[]>("browser_list_downloads", {}) diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 5b8a0b1985..8d8628c13b 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -86,6 +86,7 @@ export function setBrowserTabState(state: BrowserTabState): void { export function removeBrowserTabState(workspaceTabId: string): void { const hadNotice = notices.delete(workspaceTabId) hiddenAt.delete(workspaceTabId) + findRequests.delete(workspaceTabId) if (states.delete(workspaceTabId) || hadNotice) notify() } @@ -159,12 +160,37 @@ export function useBrowserTabNotice( ) } +// Per-tab counter of "open the find bar" requests. The page owns ⌘F while it +// has keyboard focus (the app's DOM never sees that keystroke), so the host +// forwards it as `browser://shortcut` and it arrives here; the tab view +// watches the counter rather than a boolean, so a second ⌘F on an already +// open bar still re-focuses it. +const findRequests = new Map<string, number>() + +export function requestBrowserFind(workspaceTabId: string): void { + findRequests.set(workspaceTabId, (findRequests.get(workspaceTabId) ?? 0) + 1) + notify() +} + +export function useBrowserFindRequest(workspaceTabId: string | null): number { + return useSyncExternalStore( + subscribeBrowserTabs, + () => (workspaceTabId ? (findRequests.get(workspaceTabId) ?? 0) : 0), + getServerZero + ) +} + +function getServerZero(): number { + return 0 +} + export function resetBrowserTabStoreForTests(): void { states.clear() notices.clear() listeners.clear() createdSurfaces.clear() hiddenAt.clear() + findRequests.clear() } function shallowEqualState(a: BrowserTabState, b: BrowserTabState): boolean { diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index b4b924352a..ea4a9f7586 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -137,3 +137,11 @@ export const BROWSER_CLOSED_EVENT = "browser://closed" export const BROWSER_POPUP_EVENT = "browser://popup" export const BROWSER_TELEMETRY_EVENT = "browser://telemetry" export const BROWSER_DOWNLOAD_EVENT = "browser://download" +export const BROWSER_SHORTCUT_EVENT = "browser://shortcut" + +/** A browser shortcut the page had keyboard focus for. */ +export interface BrowserShortcutPayload { + tabId: string + /** A name from the host's closed set; `find` today. */ + shortcut: string +} From fd6d0b35a7ce8b49415a11626ec1c80c7ad9e324 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 11:06:54 +0800 Subject: [PATCH 21/79] fix(browser): close the races review found in persistence, downloads and find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of the P2 batch, in three fenced slices. Every fix below came with a concrete triggering sequence; the ones I pushed back on are noted at the end. Tab persistence and surfaces: - `restoreBrowserTabs` merged instead of aborting. A tab opened before the restore ran (deep link, agent request — the capability probe is a round trip) made it skip the whole restore, and the next save then overwrote the stored set with that one tab. Now it appends what is not already open. - Surface claims carry a token. `browser_open_tab` is a round trip: if the tab was closed while it was in flight, `browser_close` reached the backend first, did nothing (no registry entry yet), and the webview that arrived afterwards stayed painted over the workspace with no tab behind it. A holder whose token is stale now closes what it built. - A mounted surface host re-creates its surface if the tab's live state disappears, and `suspendBrowserTab` refuses a tab that is on screen — the background unload could otherwise fire between a host's mount and its effect and leave the pane blank. Downloads: - Two downloads started at once could be handed the same destination: the free-path check cannot reserve on disk (the engines require a destination that does NOT exist), so the paths handed out are now held in memory until the transfer ends. - A symlink in the way counted as "nothing here" (`exists()` follows it), so the engine would have written through it to wherever it pointed. - `CON`, `NUL.txt`, `LPT1` and friends still name devices on Windows. - The history cap could evict a RUNNING download, leaving its completion with nothing to update and the bar saying "downloading" for ever. Only finished records are evictable now, and the frontend store caps itself too. - Dismissing one download's row dismissed every tab's rows. - A download no longer retires the load watcher outright: it marks the load generation, and the watcher settles quietly only for that generation. The old version swallowed the failure of a LATER navigation whose load was in flight when a delayed download callback arrived. Find in page: - ⌘F was dead whenever the caret was inside an iframe: a keystroke only reaches the focused frame, and the handler required the main frame. The tab id comes from the source webview, not the payload, so a subframe still cannot speak for another tab. - Searches are sequenced; a stale answer no longer relabels the current query. - A timeout is an error, not "no match" — WebKit may highlight a match a moment later and the bar would have been saying the opposite of the screen. - Pressing ⌘F with the bar already open re-focuses it. Pushed back, with reasons: `BrowserTabView` is keyed by tab id at both mount sites, so no state crosses tabs; and two downloads of the SAME url can still swap their record labels, because the engine's completion callback carries only the url — the files themselves land correctly. --- src-tauri/src/browser/channel.rs | 9 +- src-tauri/src/browser/downloads.rs | 180 ++++++++++++++++-- src-tauri/src/browser/hooks.rs | 35 ++-- src-tauri/src/browser/registry.rs | 7 + src-tauri/src/commands/browser.rs | 9 +- .../browser/browser-find-bar.test.tsx | 32 +++- src/components/browser/browser-find-bar.tsx | 28 ++- .../browser/browser-status-layer.tsx | 7 +- .../browser/browser-surface-host.tsx | 27 ++- src/components/browser/browser-tab-view.tsx | 5 + src/contexts/workspace-context.test.tsx | 22 ++- src/contexts/workspace-context.tsx | 59 +++--- src/lib/browser/browser-downloads-store.ts | 13 +- src/lib/browser/browser-tab-store.test.ts | 22 ++- src/lib/browser/browser-tab-store.ts | 42 ++-- 15 files changed, 401 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/browser/channel.rs b/src-tauri/src/browser/channel.rs index 0165199849..6556acdf5c 100644 --- a/src-tauri/src/browser/channel.rs +++ b/src-tauri/src/browser/channel.rs @@ -114,10 +114,13 @@ pub fn handle_message(app: &AppHandle, tab_id: &str, raw: String, main_frame: bo // A browser shortcut the page had focus for. The set is closed here, // not in the page: whatever the helper claims, only a name from this // list reaches the frontend, and it carries nothing else. + // + // Deliberately NOT gated on the main frame, unlike `hello` and + // `nav-state`: a keystroke is delivered only to the frame that holds + // focus, so gating would make ⌘F dead whenever the caret is inside an + // iframe. The tab id comes from the source webview, not from the + // payload, so a subframe still cannot speak for another tab. "shortcut" => { - if !(main_frame && envelope.top) { - return; - } let name = envelope.payload.get("name").and_then(Value::as_str); if name == Some("find") { events::emit_shortcut(app, tab_id, "find"); diff --git a/src-tauri/src/browser/downloads.rs b/src-tauri/src/browser/downloads.rs index 812d9888a6..26c5a06f75 100644 --- a/src-tauri/src/browser/downloads.rs +++ b/src-tauri/src/browser/downloads.rs @@ -15,6 +15,7 @@ //! limitation), so the destination chosen at request time is remembered here //! and matched back by URL when the transfer ends. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; @@ -54,6 +55,12 @@ pub struct BrowserDownload { #[derive(Default)] pub struct BrowserDownloads { entries: Mutex<Vec<BrowserDownload>>, + /// Destinations handed to the engine that no transfer has finished with + /// yet. `unique_path` cannot reserve a path on disk — the engines require + /// a destination that does NOT exist — so two downloads started in the + /// same instant would otherwise be handed the same free name and write + /// over each other. + reserved: Mutex<HashSet<PathBuf>>, } static DOWNLOAD_SEQ: AtomicU64 = AtomicU64::new(0); @@ -65,12 +72,26 @@ impl BrowserDownloads { .unwrap_or_else(|poisoned| poisoned.into_inner()) } + fn reserved(&self) -> std::sync::MutexGuard<'_, HashSet<PathBuf>> { + self.reserved + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + fn push(&self, download: BrowserDownload) { let mut entries = self.lock(); entries.push(download); - if entries.len() > HISTORY_LIMIT { - let overflow = entries.len() - HISTORY_LIMIT; - entries.drain(0..overflow); + // Only FINISHED records are evictable: dropping a running one would + // leave its completion with nothing to update, and the frontend would + // show it as downloading for ever. + while entries.len() > HISTORY_LIMIT { + let Some(oldest_done) = entries + .iter() + .position(|d| d.state != DownloadState::Started) + else { + break; + }; + entries.remove(oldest_done); } } @@ -94,7 +115,10 @@ impl BrowserDownloads { entry.path = path.to_string_lossy().to_string(); } } - Some(entry.clone()) + let done = entry.clone(); + drop(entries); + self.reserved().remove(Path::new(&done.path)); + Some(done) } pub fn list(&self) -> Vec<BrowserDownload> { @@ -104,6 +128,15 @@ impl BrowserDownloads { pub fn clear(&self) { self.lock().clear(); } + + /// A free destination for `file_name`, remembered so a second download + /// started before this one finishes cannot be handed the same path. + fn reserve(&self, dir: &Path, file_name: &str) -> PathBuf { + let mut reserved = self.reserved(); + let path = unique_path_excluding(dir, file_name, &reserved); + reserved.insert(path.clone()); + path + } } fn file_name_of(path: &Path) -> String { @@ -144,6 +177,11 @@ pub fn safe_file_name(suggested: &str) -> String { if cleaned.is_empty() || cleaned == ".." { return "download".to_string(); } + // `CON`, `NUL.txt`, `LPT1`… still name DEVICES on Windows, whatever the + // extension: writing there either fails or talks to hardware. Neutralised + // on every platform so a profile is portable and the tests are not + // per-OS. + let cleaned = &prefix_reserved_device_name(cleaned); // Long names are a filesystem error, not a security problem; keep the // extension by trimming the stem. const MAX: usize = 120; @@ -165,11 +203,38 @@ pub fn safe_file_name(suggested: &str) -> String { format!("{stem}{ext}") } +const WINDOWS_DEVICE_NAMES: [&str; 22] = [ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +fn prefix_reserved_device_name(name: &str) -> String { + let stem = name.split('.').next().unwrap_or(name).to_ascii_lowercase(); + if WINDOWS_DEVICE_NAMES.contains(&stem.as_str()) { + format!("_{name}") + } else { + name.to_string() + } +} + +/// Taken = anything at this path, INCLUDING a symlink (`symlink_metadata` +/// does not follow it). A dangling link reports "nothing here" to `exists()`, +/// and handing that path to the engine would write through the link to +/// wherever it points — outside the downloads directory. +fn path_is_taken(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() +} + /// `dir/name`, with ` (1)`, ` (2)`… appended before the extension until the /// path is free. A download NEVER replaces a file that is already there. pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { + unique_path_excluding(dir, file_name, &HashSet::new()) +} + +fn unique_path_excluding(dir: &Path, file_name: &str, taken: &HashSet<PathBuf>) -> PathBuf { + let free = |path: &Path| !taken.contains(path) && !path_is_taken(path); let candidate = dir.join(file_name); - if !candidate.exists() { + if free(&candidate) { return candidate; } let path = Path::new(file_name); @@ -183,7 +248,7 @@ pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { .unwrap_or_default(); for counter in 1..10_000 { let candidate = dir.join(format!("{stem} ({counter}){ext}")); - if !candidate.exists() { + if free(&candidate) { return candidate; } } @@ -208,10 +273,13 @@ pub fn requested(app: &AppHandle, tab_id: &str, url: &str, destination: &mut Pat ); return false; } + let Some(downloads) = app.try_state::<BrowserDownloads>() else { + return false; + }; // The engine already suggested a name (and on macOS a whole path); only // its last component is used, and only after sanitising. let file_name = safe_file_name(&file_name_of(destination)); - let path = unique_path(&dir, &file_name); + let path = downloads.reserve(&dir, &file_name); *destination = path.clone(); let seq = DOWNLOAD_SEQ.fetch_add(1, Ordering::SeqCst) + 1; @@ -223,11 +291,10 @@ pub fn requested(app: &AppHandle, tab_id: &str, url: &str, destination: &mut Pat path: path.to_string_lossy().to_string(), state: DownloadState::Started, }; - if let Some(downloads) = app.try_state::<BrowserDownloads>() { - downloads.push(download.clone()); - } - // A click that turns into a download leaves the tab's navigation - // unfinished for ever; tell the tab so its load watcher stands down. + downloads.push(download.clone()); + // The navigation that produced this download will never commit; mark the + // generation so its load watcher settles quietly instead of reporting a + // page that failed to arrive. super::hooks::navigation_became_download(app, tab_id); events::emit_download(app, &download); true @@ -315,7 +382,7 @@ mod tests { tab_id: "t1".into(), url: url.into(), file_name: "a.bin".into(), - path: "/tmp/a.bin".into(), + path: format!("/tmp/{id}.bin"), state: DownloadState::Started, }; downloads.push(record("dl-1", "https://example.com/a")); @@ -334,16 +401,99 @@ mod tests { assert_eq!(again.state, DownloadState::Failed); assert!(downloads.finish("https://example.com/a", true, None).is_none()); // A failed download keeps the path it was going to be written to. - assert_eq!(again.path, "/tmp/a.bin"); + assert_eq!(again.path, "/tmp/dl-3.bin"); + // Finished records are the evictable ones (see the cap test below). for i in 0..HISTORY_LIMIT + 5 { - downloads.push(record(&format!("x-{i}"), "https://example.com/c")); + let url = format!("https://example.com/c{i}"); + downloads.push(record(&format!("x-{i}"), &url)); + downloads.finish(&url, true, None); } assert_eq!(downloads.list().len(), HISTORY_LIMIT); downloads.clear(); assert!(downloads.list().is_empty()); } + #[test] + fn windows_device_names_cannot_survive() { + assert_eq!(safe_file_name("CON"), "_CON"); + assert_eq!(safe_file_name("nul.txt"), "_nul.txt"); + assert_eq!(safe_file_name("LPT1.tar.gz"), "_LPT1.tar.gz"); + // Only the exact stems; a name that merely starts with one is fine. + assert_eq!(safe_file_name("console.log"), "console.log"); + assert_eq!(safe_file_name("com10.txt"), "com10.txt"); + } + + /// A dangling symlink is "nothing here" to `exists()`, and the engine + /// would write through it to wherever it points. + #[cfg(unix)] + #[test] + fn a_symlink_in_the_way_counts_as_taken() { + let dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(dir.path().join("nowhere"), dir.path().join("report.pdf")) + .unwrap(); + assert!(!dir.path().join("report.pdf").exists()); + assert_eq!( + unique_path(dir.path(), "report.pdf"), + dir.path().join("report (1).pdf") + ); + } + + #[test] + fn two_downloads_started_at_once_get_different_paths() { + let dir = tempfile::tempdir().unwrap(); + let downloads = BrowserDownloads::default(); + // Neither file exists yet: without a reservation both would be told + // to write to `report.pdf`. + let first = downloads.reserve(dir.path(), "report.pdf"); + let second = downloads.reserve(dir.path(), "report.pdf"); + assert_eq!(first, dir.path().join("report.pdf")); + assert_eq!(second, dir.path().join("report (1).pdf")); + + // The reservation is released when the transfer ends, so the name is + // reusable once the file is gone again. + downloads.push(BrowserDownload { + id: "dl-1".into(), + tab_id: "t1".into(), + url: "https://example.com/report.pdf".into(), + file_name: "report.pdf".into(), + path: first.to_string_lossy().to_string(), + state: DownloadState::Started, + }); + downloads.finish("https://example.com/report.pdf", true, None); + assert_eq!(downloads.reserve(dir.path(), "report.pdf"), first); + } + + /// The cap must never drop a transfer that is still running: its + /// completion would find nothing and the UI would say "downloading" for + /// ever. + #[test] + fn the_history_cap_never_evicts_a_running_download() { + let downloads = BrowserDownloads::default(); + let record = |id: &str, state: DownloadState| BrowserDownload { + id: id.into(), + tab_id: "t1".into(), + url: format!("https://example.com/{id}"), + file_name: "a.bin".into(), + path: format!("/tmp/{id}.bin"), + state, + }; + for i in 0..HISTORY_LIMIT + 4 { + downloads.push(record(&format!("run-{i}"), DownloadState::Started)); + } + // Nothing finished, so nothing may be evicted, cap or no cap. + assert_eq!(downloads.list().len(), HISTORY_LIMIT + 4); + + downloads.finish("https://example.com/run-0", true, None); + downloads.finish("https://example.com/run-1", true, None); + downloads.push(record("newest", DownloadState::Started)); + let ids: Vec<String> = downloads.list().into_iter().map(|d| d.id).collect(); + // The two finished ones went first; every running record survived. + assert!(!ids.contains(&"run-0".to_string())); + assert!(ids.contains(&"run-2".to_string())); + assert!(ids.contains(&"newest".to_string())); + } + #[test] fn wire_names_are_camel_and_kebab() { let json = serde_json::to_value(BrowserDownload { diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index b136d50aa6..2bd65918ab 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -127,7 +127,15 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { return; } let has_document = surface.url().is_ok(); - let next = registry.update_state(&tab_id, |state| { + let next = registry.update(&tab_id, |tab| { + // This very navigation turned into a download: it was never + // going to commit, so there is nothing to report. + if tab.download_seq == Some(seq) { + tab.download_seq = None; + settle_after_download(&mut tab.state); + return tab.state.clone(); + } + let state = &mut tab.state; state.loading = false; // The load ended without the requested page: either nothing // ever committed, or an older document is still showing while @@ -149,6 +157,7 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { url: Some(url), }); } + state.clone() }); if let Some(next) = next { events::emit_state(&app, &next); @@ -159,24 +168,20 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { } /// A navigation turned into a download. Nothing will ever commit for it, so -/// the load watcher has to stand down: without this it would see "the -/// requested address never arrived" and paint the error page over the -/// document the tab is still perfectly happily showing. The tab keeps that -/// document; the download reports itself through `browser://download`. +/// the watcher armed for it must not report "the requested address never +/// arrived" and paint an error page over the document the tab is still +/// perfectly happily showing. +/// +/// This only MARKS the generation; the watcher settles when it concludes. +/// Retiring the watcher here instead would be wrong whenever the download's +/// callback arrives late: by then the tab may be loading something else, and +/// clearing that navigation's state would both stop its spinner early and +/// swallow its real failure. pub fn navigation_became_download(app: &AppHandle, tab_id: &str) { let Some(registry) = app.try_state::<BrowserRegistry>() else { return; }; - let state = registry.update(tab_id, |tab| { - // A newer `load_seq` retires the watcher armed for the navigation - // that turned out to be this download. - tab.load_seq += 1; - settle_after_download(&mut tab.state); - tab.state.clone() - }); - if let Some(state) = state { - events::emit_state(app, &state); - } + registry.update(tab_id, |tab| tab.download_seq = Some(tab.load_seq)); } pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 8716978f38..68f649662f 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -39,6 +39,12 @@ pub struct BrowserTab { /// Bumped on every navigation start; a load watcher captures it and /// stands down when a newer navigation supersedes its own. pub load_seq: u64, + /// Set to `load_seq` when a navigation turned out to be a download. That + /// navigation never commits, so its watcher must settle quietly instead + /// of reporting a page that never arrived — and keying on the GENERATION + /// rather than on the URL keeps a redirected download working while a + /// later, genuinely failing navigation still reports itself. + pub download_seq: Option<u64>, pub gestures: VecDeque<GestureRecord>, } @@ -57,6 +63,7 @@ impl BrowserTab { visible, devtools, load_seq: 0, + download_seq: None, gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), } } diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index c36fbbf289..73658cd103 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -470,11 +470,14 @@ pub async fn find_core( } }) .map_err(|e| window_err("Failed to search the page", e))?; - // A search that never answers must not hang the caller; "no match" is the - // honest thing to show then. + // A search that never answers must not hang the caller — but it must not + // be reported as "no match" either: the engine may still highlight one a + // moment later, and the bar would be saying the opposite of the screen. + // An error leaves the bar showing nothing at all. match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await { Ok(Ok(found)) => Ok(found), - _ => Ok(false), + Ok(Err(_)) => Err(window_err("Failed to search the page", "the search was dropped")), + Err(_) => Err(window_err("Failed to search the page", "WebKit did not answer")), } } diff --git a/src/components/browser/browser-find-bar.test.tsx b/src/components/browser/browser-find-bar.test.tsx index e433c22ea2..c779e6dd02 100644 --- a/src/components/browser/browser-find-bar.test.tsx +++ b/src/components/browser/browser-find-bar.test.tsx @@ -26,7 +26,7 @@ const tab = { function renderBar(open = true, onClose = vi.fn()) { const view = render( <NextIntlClientProvider locale="en" messages={enMessages}> - <BrowserFindBar tab={tab} open={open} onClose={onClose} /> + <BrowserFindBar tab={tab} open={open} focusToken={0} onClose={onClose} /> </NextIntlClientProvider> ) return { ...view, onClose } @@ -89,6 +89,29 @@ describe("BrowserFindBar", () => { expect(mocks.browserFind).toHaveBeenLastCalledWith("abc", "", true) }) + // Each search is a round trip; an answer that arrives after a newer search + // must not relabel the query on screen now. + it("ignores an answer that a newer search has superseded", async () => { + let settleFirst: (found: boolean) => void = () => {} + mocks.browserFind + .mockImplementationOnce( + () => new Promise<boolean>((resolve) => (settleFirst = resolve)) + ) + .mockResolvedValueOnce(false) + renderBar() + const input = screen.getByLabelText("Find in page") + await act(async () => { + fireEvent.change(input, { target: { value: "a" } }) + fireEvent.change(input, { target: { value: "az" } }) + }) + // The newer search already said "no matches". + expect(screen.getByText("No matches")).toBeInTheDocument() + await act(async () => { + settleFirst(true) + }) + expect(screen.getByText("No matches")).toBeInTheDocument() + }) + it("closes on Escape and drops the highlight when it goes away", async () => { const onClose = vi.fn() const { rerender } = renderBar(true, onClose) @@ -99,7 +122,12 @@ describe("BrowserFindBar", () => { await act(async () => { rerender( <NextIntlClientProvider locale="en" messages={enMessages}> - <BrowserFindBar tab={tab} open={false} onClose={onClose} /> + <BrowserFindBar + tab={tab} + open={false} + focusToken={0} + onClose={onClose} + /> </NextIntlClientProvider> ) }) diff --git a/src/components/browser/browser-find-bar.tsx b/src/components/browser/browser-find-bar.tsx index 456fa2a402..0dc01acae2 100644 --- a/src/components/browser/browser-find-bar.tsx +++ b/src/components/browser/browser-find-bar.tsx @@ -28,10 +28,13 @@ const ICON_BTN = export function BrowserFindBar({ tab, open, + focusToken, onClose, }: { tab: BrowserWorkspaceTab open: boolean + /** Bumped every time the user asks for the bar; re-focuses an open one. */ + focusToken: number onClose: () => void }) { const t = useTranslations("Browser.find") @@ -39,18 +42,29 @@ export function BrowserFindBar({ const [query, setQuery] = useState("") const [missing, setMissing] = useState(false) const inputRef = useRef<HTMLInputElement | null>(null) + // Searches are answered out of order (each is a round trip to the engine): + // a stale answer must not relabel the query on screen now. + const searchSeq = useRef(0) const step = useCallback( (text: string, forward: boolean) => { if (!backendId) return + searchSeq.current += 1 + const seq = searchSeq.current if (!text) { setMissing(false) void browserFind(backendId, "", true).catch(() => {}) return } void browserFind(backendId, text, forward) - .then((found) => setMissing(!found)) - .catch(() => setMissing(false)) + .then((found) => { + if (searchSeq.current === seq) setMissing(!found) + }) + .catch(() => { + // Includes the host's "WebKit did not answer": say nothing rather + // than claim there are no matches. + if (searchSeq.current === seq) setMissing(false) + }) }, [backendId] ) @@ -66,17 +80,21 @@ export function BrowserFindBar({ } // Opening (or re-pressing ⌘F) selects what is there, the way a browser's - // find bar does, so a second search replaces the first by typing. + // find bar does, so a second search replaces the first by typing. Keyed on + // `focusToken` as well: pressing ⌘F again with the bar already open must + // pull focus back out of the page, and `open` alone does not change then. useEffect(() => { if (!open) return inputRef.current?.focus() inputRef.current?.select() - }, [open]) + }, [open, focusToken]) // Closing drops the engine's highlight; leaving it behind would look like - // a page selection the user cannot get rid of. + // a page selection the user cannot get rid of. Counts as a search so any + // answer still in flight is ignored when it lands. useEffect(() => { if (open || !backendId) return + searchSeq.current += 1 void browserFind(backendId, "", true).catch(() => {}) }, [open, backendId]) diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index d83d46ee17..c399c0c742 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -16,7 +16,6 @@ import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" import { useOptionalWorkspaceActions } from "@/contexts/workspace-context" import { browserReload } from "@/lib/browser/browser-api" import { - dismissAllBrowserDownloads, dismissBrowserDownload, useBrowserTabDownloads, } from "@/lib/browser/browser-downloads-store" @@ -237,11 +236,7 @@ export function BrowserDownloadBar({ tab }: { tab: BrowserWorkspaceTab }) { className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-primary/8" title={t("dismiss")} aria-label={t("dismiss")} - onClick={() => - downloads.length > 1 - ? dismissBrowserDownload(download.id) - : dismissAllBrowserDownloads() - } + onClick={() => dismissBrowserDownload(download.id)} > <X className="h-3.5 w-3.5" /> </button> diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index 2ce7e5717b..4e497eb6ed 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -12,6 +12,7 @@ import { browserSetVisible, } from "@/lib/browser/browser-api" import { getBrowserPrefs } from "@/lib/browser/browser-prefs" +import { browserClose } from "@/lib/browser/browser-api" import { claimSurfaceCreation, forgetSurfaceCreation, @@ -19,6 +20,8 @@ import { markBrowserTabHidden, markBrowserTabShown, setBrowserTabState, + surfaceClaimIsCurrent, + useBrowserTabState, } from "@/lib/browser/browser-tab-store" import { useFallbackOverlayOpen, @@ -121,6 +124,13 @@ export function BrowserSurfaceHost({ } }, [backendId, shouldShow, tab.id]) + // Whether this tab currently has a live surface. Also the re-creation + // signal: if the state goes away while this host is mounted — the tab was + // released by the background unload just as the user switched to it — the + // effect below runs again and loads the page instead of leaving a blank + // pane behind. + const loaded = useBrowserTabState(tab.id) !== null + // Create the surface once per tab record; adopted popups and re-mounts // already have one (the store knows about it). A record whose surface was // released (background unload) is "not loaded" again and gets a new one @@ -128,12 +138,17 @@ export function BrowserSurfaceHost({ useEffect(() => { const el = ref.current if (!el || !backendId) return - if (getBrowserTabState(tab.id) || !claimSurfaceCreation(backendId)) { + if (getBrowserTabState(tab.id)) { lastBoundsRef.current = null lastVisibleRef.current = null sync() return } + const token = claimSurfaceCreation(backendId) + if (token === null) { + sync() + return + } const bounds = measure(el) lastBoundsRef.current = bounds lastVisibleRef.current = true @@ -149,17 +164,25 @@ export function BrowserSurfaceHost({ devtools: prefs.devtools, }) .then((next) => { + // The tab was closed (or released and re-claimed) while the backend + // was building this webview: nobody owns it, so close it rather than + // leave a native view painted over the workspace for ever. + if (!surfaceClaimIsCurrent(backendId, token)) { + void browserClose(backendId).catch(() => {}) + return + } setBrowserTabState(next) sync() }) .catch((error: unknown) => { + if (!surfaceClaimIsCurrent(backendId, token)) return forgetSurfaceCreation(backendId) setCreateError(String(error)) }) // Intentionally not re-run on `sync` identity changes: creation is a // one-shot per mount, the effect below handles every later sync. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendId, tab.id, tab.browser.initialUrl, tab.folderId]) + }, [backendId, tab.id, tab.browser.initialUrl, tab.folderId, loaded]) // Geometry and visibility tracking for the life of the mount. useEffect(() => { diff --git a/src/components/browser/browser-tab-view.tsx b/src/components/browser/browser-tab-view.tsx index e093f7c8fd..1cec63f426 100644 --- a/src/components/browser/browser-tab-view.tsx +++ b/src/components/browser/browser-tab-view.tsx @@ -43,11 +43,15 @@ export function BrowserTabView({ tab }: { tab: BrowserWorkspaceTab }) { // ⌘F pressed while the focus is in this view's own DOM (address bar, find // bar). Bound to the container rather than the window so the shortcut only // belongs to the browser when the browser is what the user is in. + // ⌘F from this view's own DOM counts the same way, so pressing it twice + // re-focuses the bar instead of doing nothing. + const [localFindRequest, setLocalFindRequest] = useState(0) const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (event.altKey || event.key.toLowerCase() !== "f") return if (!event.metaKey && !event.ctrlKey) return event.preventDefault() setFindOpen(true) + setLocalFindRequest((n) => n + 1) } const url = state?.url || state?.requestedUrl || tab.browser.initialUrl const error = state?.error ?? null @@ -59,6 +63,7 @@ export function BrowserTabView({ tab }: { tab: BrowserWorkspaceTab }) { <BrowserFindBar tab={tab} open={findOpen && !ownedWindow} + focusToken={findRequest + localFindRequest} onClose={() => setFindOpen(false)} /> <BrowserNoticeBar tab={tab} state={state} /> diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx index 0cf6c185f7..ac1175948a 100644 --- a/src/contexts/workspace-context.test.tsx +++ b/src/contexts/workspace-context.test.tsx @@ -11,6 +11,7 @@ import { import * as api from "@/lib/api" import { getBrowserTabState, + markBrowserTabHidden, resetBrowserTabStoreForTests, setBrowserTabState, } from "@/lib/browser/browser-tab-store" @@ -3503,7 +3504,10 @@ describe("browser tabs", () => { expect(readTabs()).toHaveLength(2) }) - it("does not restore over tabs this window already has", () => { + // A tab opened before the restore ran (a deep link, an agent request — + // the capability probe is a round trip) must not cost the user the stored + // set; and a page already open must not be duplicated. + it("merges a restore into tabs this window already has", () => { render( <WorkspaceProvider> <BrowserProbe /> @@ -3511,7 +3515,15 @@ describe("browser tabs", () => { ) act(() => screen.getByText("open").click()) act(() => screen.getByText("restore").click()) - expect(readTabs()).toHaveLength(1) + expect(readTabs().map((t) => t.url)).toEqual([ + "https://example.com/docs#top", + "https://restored.example/one", + "https://restored.example/two", + ]) + + // The one-tab-per-URL rule holds across a repeat restore. + act(() => screen.getByText("restore").click()) + expect(readTabs()).toHaveLength(3) }) it("suspending a loaded tab keeps the record at the page it was showing", () => { @@ -3543,6 +3555,12 @@ describe("browser tabs", () => { openerTabId: null, }) ) + // Only a tab that is off screen may be released; the suspender's own + // bookkeeping says so, and the action re-checks it. + act(() => screen.getByText("suspend-first").click()) + expect(readTabs()[0].url).toBe("https://example.com/docs#top") + act(() => markBrowserTabHidden(opened.id)) + act(() => screen.getByText("suspend-first").click()) const tabs = readTabs() expect(tabs).toHaveLength(1) diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index f5676d0640..8885e7cfb6 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -67,6 +67,7 @@ import { } from "@/hooks/use-open-file-tabs-watch" import { useOfficeAutoPreview } from "@/lib/office-preview-prefs" import { + browserTabHiddenAt, getBrowserTabState, releaseBrowserTab, } from "@/lib/browser/browser-tab-store" @@ -817,28 +818,37 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const restoreBrowserTabs = useCallback( (entries: RestorableBrowserTab[]) => { if (entries.length === 0) return - if (fileTabsRef.current.some((tab) => tab.kind === "browser")) return - const records = entries.flatMap((entry) => - normalizeUrlForDedupe(entry.url) - ? [ - browserTabRecord( - crypto.randomUUID(), - entry.url, - entry.folderId, - null, - entry.title - ), - ] - : [] - ) - if (records.length === 0) return - // Records only; not activated, so no surface is created until the user - // switches to one. The pane state is left exactly as it was. - setFileTabs((prev) => - prev.some((tab) => tab.kind === "browser") - ? prev - : [...prev, ...records] - ) + setFileTabs((prev) => { + // Merge, never replace: a tab opened before the restore ran (a deep + // link, an agent request — the capability probe is a round trip) must + // not cost the user the whole stored set. Same one-tab-per-URL rule + // as `openBrowserTab`, so a page already open is not duplicated. + const open = new Set( + prev.flatMap((tab) => + tab.kind === "browser" + ? [normalizeUrlForDedupe(tab.browser.initialUrl) ?? ""] + : [] + ) + ) + const records: FileWorkspaceTab[] = [] + for (const entry of entries) { + const normalized = normalizeUrlForDedupe(entry.url) + if (!normalized || open.has(normalized)) continue + open.add(normalized) + records.push( + browserTabRecord( + crypto.randomUUID(), + entry.url, + entry.folderId, + null, + entry.title + ) + ) + } + // Records only; not activated, so no surface is created until the + // user switches to one. The pane state is left exactly as it was. + return records.length === 0 ? prev : [...prev, ...records] + }) }, [browserTabRecord] ) @@ -848,6 +858,11 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { if (!tab || tab.kind !== "browser") return false const state = getBrowserTabState(tabId) if (!state) return false + // Re-checked here, not just by the caller: the surface host may have + // mounted (the user switched to this tab) between the caller picking it + // and this call. `null` means "on screen right now", `undefined` "never + // shown by this document" — releasing either would blank a live pane. + if (typeof browserTabHiddenAt(tabId) !== "number") return false const url = state.url || state.requestedUrl || tab.browser.initialUrl const title = state.title || tab.title // Move the record to where the page got to before letting the surface diff --git a/src/lib/browser/browser-downloads-store.ts b/src/lib/browser/browser-downloads-store.ts index e681636a0f..5bed04bcc7 100644 --- a/src/lib/browser/browser-downloads-store.ts +++ b/src/lib/browser/browser-downloads-store.ts @@ -12,8 +12,12 @@ import type { BrowserDownload } from "./types" type Listener = () => void +/** Mirrors the backend's own history cap so a long session cannot accumulate + * records for ever. */ +export const BROWSER_DOWNLOAD_HISTORY_LIMIT = 32 + const listeners = new Set<Listener>() -// Newest first; the backend caps its own history, this mirrors it. +// Newest first. let downloads: BrowserDownload[] = [] const dismissed = new Set<string>() @@ -43,14 +47,17 @@ export function setBrowserDownload(download: BrowserDownload): void { downloads = [...downloads] downloads[index] = download } else { - downloads = [download, ...downloads] + downloads = [download, ...downloads].slice( + 0, + BROWSER_DOWNLOAD_HISTORY_LIMIT + ) } notify() } /** Initial list from the backend (oldest first there, newest first here). */ export function hydrateBrowserDownloads(list: BrowserDownload[]): void { - downloads = [...list].reverse() + downloads = [...list].reverse().slice(0, BROWSER_DOWNLOAD_HISTORY_LIMIT) notify() } diff --git a/src/lib/browser/browser-tab-store.test.ts b/src/lib/browser/browser-tab-store.test.ts index 0f113ea3d2..3d0e8ff31b 100644 --- a/src/lib/browser/browser-tab-store.test.ts +++ b/src/lib/browser/browser-tab-store.test.ts @@ -13,6 +13,7 @@ import { browserWorkspaceTabId, claimSurfaceCreation, forgetSurfaceCreation, + surfaceClaimIsCurrent, getBrowserTabState, markBrowserTabHidden, markBrowserTabShown, @@ -108,10 +109,19 @@ describe("browser tab store", () => { // The ledger is what stops a StrictMode double effect (or a re-mounted // host) from creating a second webview for one tab. it("hands the surface-creation claim to exactly one caller until released", () => { - expect(claimSurfaceCreation("abc")).toBe(true) - expect(claimSurfaceCreation("abc")).toBe(false) + const token = claimSurfaceCreation("abc") + expect(token).not.toBeNull() + expect(claimSurfaceCreation("abc")).toBeNull() + expect(surfaceClaimIsCurrent("abc", token!)).toBe(true) + forgetSurfaceCreation("abc") - expect(claimSurfaceCreation("abc")).toBe(true) + // The old holder can now tell that the surface it is building is nobody's. + expect(surfaceClaimIsCurrent("abc", token!)).toBe(false) + const next = claimSurfaceCreation("abc") + expect(next).not.toBeNull() + expect(next).not.toBe(token) + // A claim taken meanwhile does not make the old token current again. + expect(surfaceClaimIsCurrent("abc", token!)).toBe(false) }) // Releasing a tab returns it to "not loaded": the next host that mounts @@ -119,9 +129,11 @@ describe("browser tab store", () => { // restored) tab resumable through the same code path. it("releasing a tab frees its claim", () => { setBrowserTabState(state()) - expect(claimSurfaceCreation("abc")).toBe(true) + const token = claimSurfaceCreation("abc") + expect(token).not.toBeNull() releaseBrowserTab("browser:abc") - expect(claimSurfaceCreation("abc")).toBe(true) + expect(surfaceClaimIsCurrent("abc", token!)).toBe(false) + expect(claimSurfaceCreation("abc")).not.toBeNull() }) it("stamps when a tab left the screen", () => { diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 8d8628c13b..610bcaf11e 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -18,19 +18,35 @@ type Listener = () => void const states = new Map<string, BrowserTabState>() const listeners = new Set<Listener>() -// Backend ids whose surface this document has asked the backend to create. -// The surface host consults it so a StrictMode double effect or a re-mount of -// the same tab never asks twice: the webview lives as long as the tab record, -// not as long as the host component. Released together with the state, which -// is what lets a suspended tab be brought back through the same host. -const createdSurfaces = new Set<string>() - -/** Record that a surface is being created for `backendTabId`; false when it - * already was. */ -export function claimSurfaceCreation(backendTabId: string): boolean { - if (createdSurfaces.has(backendTabId)) return false - createdSurfaces.add(backendTabId) - return true +// Backend ids whose surface this document has asked the backend to create, +// each with the token of the claim that asked. The surface host consults it +// so a StrictMode double effect or a re-mount of the same tab never asks +// twice: the webview lives as long as the tab record, not as long as the host +// component. Released together with the state, which is what lets a suspended +// tab be brought back through the same host. +// +// The token exists because `browser_open_tab` is a round trip: by the time it +// answers, the tab may have been closed (claim released) or shown again +// (claim re-taken). A holder whose token is no longer the current one owns a +// surface nobody is going to use, and must close it — otherwise the native +// webview stays painted over the workspace with no tab behind it. +const createdSurfaces = new Map<string, number>() +let nextClaimToken = 0 + +/** Claim the right to create a surface; null when someone already holds it. */ +export function claimSurfaceCreation(backendTabId: string): number | null { + if (createdSurfaces.has(backendTabId)) return null + nextClaimToken += 1 + createdSurfaces.set(backendTabId, nextClaimToken) + return nextClaimToken +} + +/** Whether `token` is still the live claim for this tab. */ +export function surfaceClaimIsCurrent( + backendTabId: string, + token: number +): boolean { + return createdSurfaces.get(backendTabId) === token } export function forgetSurfaceCreation(backendTabId: string): void { From 30466744285acb386c0f4ec7e8c52759bc35fcf0 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 11:31:13 +0800 Subject: [PATCH 22/79] fix(browser): order the create and destroy calls of one tab id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of the previous fix found the race it left behind. A backend tab id is reused across generations — a suspended tab is released and, when the user comes back to it, created again under the same id — and both commands are round trips the backend may run in either order. The close issued for generation 1 could therefore arrive after generation 2 had registered and destroy the live surface, leaving a tab that believes it is loaded showing nothing. Lifecycle calls for one id now go through a per-id promise chain, so they reach the backend in the order they were issued. An idle chain still calls straight through (closing a surface should not wait for a microtask), a failed op does not stall what is queued behind it, and the chain is dropped once it drains. The stale-token cleanup only closes when nobody holds the claim: if the id was re-claimed, that owner's create is already queued behind us and a close from here would land after it. Also from the re-review: a download in an owned window left the tab loading for ever. Owned windows have no load watcher — `is_loading` has no handle to answer from — so nothing consumed the generation mark the previous commit introduced. They settle immediately instead. Verified while checking whether the download could be correlated by URL instead: a navigation that 302s to a file reports the FINAL url, not the one navigated to. Matching on the url would therefore put an error page over a perfectly good page for every redirected download, so the generation mark stays, with its known limit written down. --- src-tauri/src/browser/hooks.rs | 31 ++++++++++- .../browser/browser-surface-host.tsx | 35 ++++++++---- src/lib/browser/browser-tab-store.test.ts | 55 +++++++++++++++++++ src/lib/browser/browser-tab-store.ts | 44 ++++++++++++++- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index 2bd65918ab..7cd675b259 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -177,11 +177,40 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { /// callback arrives late: by then the tab may be loading something else, and /// clearing that navigation's state would both stop its spinner early and /// swallow its real failure. +/// +/// Known limit: the engine does not say which navigation a download came +/// from, and for a redirected download the reported URL is the FINAL one +/// (verified on macOS: navigating to a URL that 302s to a file reports the +/// file's URL), so the address cannot be used to correlate either. A download +/// callback that arrives after a later navigation has started therefore marks +/// that navigation's generation, and if it then fails its error is not +/// reported. The alternative — matching on the URL — would put an error page +/// over a perfectly good page for every redirected download, which is the +/// common case. +/// +/// Owned windows have no load watcher at all (`is_loading` has no handle to +/// answer from), so nothing would ever consume the mark: they settle here and +/// now, or the tab would spin for ever. pub fn navigation_became_download(app: &AppHandle, tab_id: &str) { let Some(registry) = app.try_state::<BrowserRegistry>() else { return; }; - registry.update(tab_id, |tab| tab.download_seq = Some(tab.load_seq)); + let embedded = registry + .surface(tab_id) + .map(|surface| surface.is_embedded()) + .unwrap_or(false); + if embedded { + registry.update(tab_id, |tab| tab.download_seq = Some(tab.load_seq)); + return; + } + let state = registry.update(tab_id, |tab| { + tab.download_seq = None; + settle_after_download(&mut tab.state); + tab.state.clone() + }); + if let Some(state) = state { + events::emit_state(app, &state); + } } pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index 4e497eb6ed..df762f14b1 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -17,8 +17,10 @@ import { claimSurfaceCreation, forgetSurfaceCreation, getBrowserTabState, + hasSurfaceClaim, markBrowserTabHidden, markBrowserTabShown, + runSurfaceOp, setBrowserTabState, surfaceClaimIsCurrent, useBrowserTabState, @@ -155,20 +157,29 @@ export function BrowserSurfaceHost({ // Preferences are read once, here: a surface cannot change its inspector // or its kind after it exists, so a settings change applies to new tabs. const prefs = getBrowserPrefs() - browserOpenTab({ - tabId: backendId, - url: tab.browser.initialUrl, - bounds, - folderId: tab.folderId, - surface: prefs.surfaceOverride, - devtools: prefs.devtools, - }) + // Queued per tab id: a close issued for an earlier generation must reach + // the backend before this create, never after it. + runSurfaceOp(backendId, () => + browserOpenTab({ + tabId: backendId, + url: tab.browser.initialUrl, + bounds, + folderId: tab.folderId, + surface: prefs.surfaceOverride, + devtools: prefs.devtools, + }) + ) .then((next) => { - // The tab was closed (or released and re-claimed) while the backend - // was building this webview: nobody owns it, so close it rather than - // leave a native view painted over the workspace for ever. if (!surfaceClaimIsCurrent(backendId, token)) { - void browserClose(backendId).catch(() => {}) + // Someone else claimed this id meanwhile: their own create is + // already queued behind us, and closing here would land after it + // and destroy THEIR surface. Only clean up when the id is + // ownerless — the tab was closed while this was in flight. + if (!hasSurfaceClaim(backendId)) { + void runSurfaceOp(backendId, () => browserClose(backendId)).catch( + () => {} + ) + } return } setBrowserTabState(next) diff --git a/src/lib/browser/browser-tab-store.test.ts b/src/lib/browser/browser-tab-store.test.ts index 3d0e8ff31b..82a9f0904f 100644 --- a/src/lib/browser/browser-tab-store.test.ts +++ b/src/lib/browser/browser-tab-store.test.ts @@ -13,6 +13,8 @@ import { browserWorkspaceTabId, claimSurfaceCreation, forgetSurfaceCreation, + hasSurfaceClaim, + runSurfaceOp, surfaceClaimIsCurrent, getBrowserTabState, markBrowserTabHidden, @@ -117,6 +119,7 @@ describe("browser tab store", () => { forgetSurfaceCreation("abc") // The old holder can now tell that the surface it is building is nobody's. expect(surfaceClaimIsCurrent("abc", token!)).toBe(false) + expect(hasSurfaceClaim("abc")).toBe(false) const next = claimSurfaceCreation("abc") expect(next).not.toBeNull() expect(next).not.toBe(token) @@ -136,6 +139,58 @@ describe("browser tab store", () => { expect(claimSurfaceCreation("abc")).not.toBeNull() }) + // A backend tab id is reused across generations (suspend, then show + // again). Without ordering, the close issued for the old generation could + // reach the backend after the new one registered and destroy it. + it("runs the create and destroy calls of one tab id in order", async () => { + const order: string[] = [] + const settle: Array<() => void> = [] + const op = (name: string) => () => + new Promise<void>((resolve) => { + order.push(`${name}:start`) + settle.push(() => { + order.push(`${name}:done`) + resolve() + }) + }) + + const first = runSurfaceOp("abc", op("close")) + const second = runSurfaceOp("abc", op("open")) + // The second has not even started: it is waiting on the first. + expect(order).toEqual(["close:start"]) + + settle[0]() + await first + await Promise.resolve() + expect(order).toEqual(["close:start", "close:done", "open:start"]) + settle[1]() + await second + expect(order).toEqual([ + "close:start", + "close:done", + "open:start", + "open:done", + ]) + + // A different tab id is an independent chain. + let otherStarted = false + void runSurfaceOp("xyz", () => { + otherStarted = true + return Promise.resolve() + }) + await Promise.resolve() + expect(otherStarted).toBe(true) + }) + + // A failed op must not stall everything queued behind it. + it("keeps the chain moving after a failed op", async () => { + const failed = runSurfaceOp("abc", () => Promise.reject(new Error("nope"))) + await expect(failed).rejects.toThrow("nope") + await expect( + runSurfaceOp("abc", () => Promise.resolve("ok")) + ).resolves.toBe("ok") + }) + it("stamps when a tab left the screen", () => { expect(browserTabHiddenAt("browser:abc")).toBeUndefined() markBrowserTabShown("browser:abc") diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 610bcaf11e..328af44ca1 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -49,10 +49,51 @@ export function surfaceClaimIsCurrent( return createdSurfaces.get(backendTabId) === token } +/** Whether anyone currently holds the claim for this tab. */ +export function hasSurfaceClaim(backendTabId: string): boolean { + return createdSurfaces.has(backendTabId) +} + export function forgetSurfaceCreation(backendTabId: string): void { createdSurfaces.delete(backendTabId) } +// One promise chain per backend tab id, so the create and destroy calls for +// an id happen in the order they were issued. +// +// A backend tab id is reused across generations: a suspended tab is released +// and, when the user comes back to it, created again under the SAME id. Both +// commands are round trips, and without this the backend could run them in +// either order — a close issued for generation 1 arriving after generation 2 +// had registered would destroy the live surface and leave a tab that believes +// it is loaded showing nothing. +const surfaceOps = new Map<string, Promise<unknown>>() + +export function runSurfaceOp<T>( + backendTabId: string, + op: () => Promise<T> +): Promise<T> { + const previous = surfaceOps.get(backendTabId) + // With nothing in flight the call goes out now — a decision to close a + // surface should not wait for a microtask. Otherwise it queues, and runs + // whether the previous op resolved or rejected: a failed close must not + // stall every later operation on this tab. + const next = previous ? previous.then(op, op) : op() + const settled = next.then( + () => {}, + () => {} + ) + surfaceOps.set(backendTabId, settled) + // Drop the chain once it drains, so a long session does not keep an entry + // for every tab id it has ever seen. + void settled.then(() => { + if (surfaceOps.get(backendTabId) === settled) { + surfaceOps.delete(backendTabId) + } + }) + return next +} + // When each tab's surface host last went away (`null` while one is mounted). // A host is mounted exactly while the tab is on screen — the active tab of a // pane or the viewer drawer — so this is "how long has this page been in the @@ -142,7 +183,7 @@ export function releaseBrowserTab(workspaceTabId: string): void { if (!backendId) return forgetSurfaceCreation(backendId) if (isDesktop()) { - void browserClose(backendId).catch(() => { + void runSurfaceOp(backendId, () => browserClose(backendId)).catch(() => { /* already gone */ }) } @@ -205,6 +246,7 @@ export function resetBrowserTabStoreForTests(): void { notices.clear() listeners.clear() createdSurfaces.clear() + surfaceOps.clear() hiddenAt.clear() findRequests.clear() } From 4b5bac0da35083a9d0a6ffea5f23507060f3ddf6 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 14:05:37 +0800 Subject: [PATCH 23/79] feat(browser): site rules, typed load errors and freeze frames under overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site rules (W2.5): a per-site table in Settings → Built-in browser — always the built-in browser, always the system browser, or block — with the most specific pattern winning (hostname, *.suffix, *, optional :port). The link decision consults it; the backend enforces `block` on every navigation a tab attempts, on pop-ups and on the open/navigate commands, showing a block page. An administrator's policy file (`policy.json` in the machine-wide config directory, or CODEG_POLICY_FILE) can fix rules and turn the feature off; its rules show read-only in the settings section and are consulted first. Load errors (W2.4): a wrapper around wry's WKNavigationDelegate reports provisional starts and failures, so a failed load shows the right error page (DNS / TLS / other, with the engine's own wording) the moment WebKit gives up instead of after the load watcher's poll, and page-initiated navigations update the requested address. A load WebKit refuses silently — a restricted port commits an empty document instead of failing — is recognised by the blank commit standing in for the page that was started. Refused top-level navigations (a scheme not allowed in a tab, a blocked host) now show a notice on the tab; a mailto:/tel: the page pointed at can be handed to the OS from it. Frames may hold about:srcdoc, data: and blob: content, which the top-level allow-list used to refuse. Freeze frames (W2.4): hiding a surface under an overlay first captures the page's last frame (JPEG through AppKit, a few milliseconds) and the placeholder paints it until the surface shows again, so a dialog or menu opens over the page rather than over a blank pane. A visibility request that a newer one overtook while capturing is dropped instead of applied late. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 6 +- src-tauri/src/browser/events.rs | 18 +- src-tauri/src/browser/hooks.rs | 196 +++++- src-tauri/src/browser/policy.rs | 585 +++++++++++++++++- src-tauri/src/browser/registry.rs | 12 + src-tauri/src/browser/shim/macos.rs | 264 +++++++- src-tauri/src/browser/smoke.rs | 34 +- src-tauri/src/browser/surface.rs | 12 + src-tauri/src/browser/surface_child.rs | 129 +++- src-tauri/src/browser/surface_window.rs | 24 +- src-tauri/src/browser/types.rs | 52 ++ src-tauri/src/commands/browser.rs | 177 +++++- src-tauri/src/lib.rs | 2 + src/components/ai-elements/link-safety.tsx | 3 +- .../browser/browser-events-bridge.test.tsx | 50 ++ .../browser/browser-events-bridge.tsx | 31 + .../browser/browser-status-layer.test.tsx | 98 +++ .../browser/browser-status-layer.tsx | 100 ++- .../browser/browser-surface-host.test.tsx | 93 ++- .../browser/browser-surface-host.tsx | 48 +- .../browser/browser-tabs-persistence.test.tsx | 3 + .../settings/browser-settings.test.tsx | 60 ++ src/components/settings/browser-settings.tsx | 216 ++++++- src/hooks/use-open-url-target.test.tsx | 1 + src/hooks/use-open-url-target.ts | 22 +- src/i18n/messages/ar.json | 27 +- src/i18n/messages/de.json | 27 +- src/i18n/messages/en.json | 27 +- src/i18n/messages/es.json | 27 +- src/i18n/messages/fr.json | 27 +- src/i18n/messages/ja.json | 27 +- src/i18n/messages/ko.json | 27 +- src/i18n/messages/pt.json | 27 +- src/i18n/messages/zh-CN.json | 27 +- src/i18n/messages/zh-TW.json | 27 +- src/lib/browser/browser-api.ts | 34 +- src/lib/browser/browser-prefs.test.ts | 47 ++ src/lib/browser/browser-prefs.ts | 33 + src/lib/browser/browser-tab-store.ts | 11 +- src/lib/browser/host-rules.test.ts | 160 +++++ src/lib/browser/host-rules.ts | 193 ++++++ src/lib/browser/types.test.ts | 22 + src/lib/browser/types.ts | 38 ++ src/lib/resolve-link-action.test.ts | 41 +- src/lib/resolve-link-action.ts | 76 +-- 46 files changed, 2973 insertions(+), 189 deletions(-) create mode 100644 src/lib/browser/host-rules.test.ts create mode 100644 src/lib/browser/host-rules.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 73a66054a8..d268e6bb35 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4049,6 +4049,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04062350e3..a209715c1b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -181,9 +181,9 @@ mac-notification-sys = "0.6" # with the handles wry hands out. objc2 = "0.6" block2 = "0.6" -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet", "NSProcessInfo", "NSUUID"] } -objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKFindConfiguration", "WKFindResult", "WKNavigation", "WKWebsiteDataStore", "WKWebsiteDataRecord"] } -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData", "NSDictionary", "NSError", "NSObject", "NSArray", "NSValue", "NSURLRequest", "NSURL", "NSEnumerator", "NSObjCRuntime", "NSGeometry", "NSRange", "NSThread", "NSDate", "NSSet", "NSProcessInfo", "NSUUID", "NSURLError"] } +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-app-kit", "objc2-core-foundation", "WKWebView", "WKWebViewConfiguration", "WKUserContentController", "WKUserScript", "WKContentWorld", "WKScriptMessage", "WKScriptMessageHandler", "WKFrameInfo", "WKSnapshotConfiguration", "WKFindConfiguration", "WKFindResult", "WKNavigation", "WKNavigationAction", "WKNavigationDelegate", "WKNavigationResponse", "WKWebsiteDataStore", "WKWebsiteDataRecord"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics", "NSGraphicsContext", "NSResponder", "NSView", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Threading"] } diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index 59c97ea171..b3f45f5278 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -7,8 +7,10 @@ use crate::web::event_bridge::{emit_event, EventEmitter}; use super::downloads::{BrowserDownload, DOWNLOAD_EVENT}; use super::types::{ - BrowserClosedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserShortcutPayload, - BrowserTabState, CLOSED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, SHORTCUT_EVENT, STATE_EVENT, + BrowserClosedPayload, BrowserNavigationBlockedPayload, BrowserOpenRequestPayload, + BrowserPopupPayload, BrowserShortcutPayload, BrowserTabState, NavigationBlockReason, + CLOSED_EVENT, NAVIGATION_BLOCKED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, SHORTCUT_EVENT, + STATE_EVENT, }; pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { @@ -48,3 +50,15 @@ pub fn emit_shortcut(app: &AppHandle, tab_id: &str, shortcut: &str) { pub fn emit_download(app: &AppHandle, download: &BrowserDownload) { emit_event(&EventEmitter::Tauri(app.clone()), DOWNLOAD_EVENT, download); } + +pub fn emit_navigation_blocked(app: &AppHandle, tab_id: &str, url: &str, reason: NavigationBlockReason) { + emit_event( + &EventEmitter::Tauri(app.clone()), + NAVIGATION_BLOCKED_EVENT, + BrowserNavigationBlockedPayload { + tab_id: tab_id.to_string(), + url: url.to_string(), + reason, + }, + ); +} diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index 7cd675b259..df1d82d0fa 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, Manager, Url}; use super::events; use super::registry::BrowserRegistry; -use super::types::{BrowserErrorInfo, BrowserErrorKind}; +use super::types::{BrowserErrorInfo, BrowserErrorKind, NavigationBlockReason}; pub fn origin_of(url: &Url) -> Option<String> { let origin = url.origin(); @@ -19,7 +19,20 @@ pub fn origin_of(url: &Url) -> Option<String> { } } +/// A commit of `about:blank` while the navigation the engine had started was +/// for somewhere else. WebKit refuses some loads without ever reporting a +/// failure — a request to a restricted port (1, 7, 25, … the list every +/// browser keeps) is answered by committing an empty document in place of +/// the page — and this is the only trace it leaves. A page that navigates +/// itself to `about:blank` announces that URL as its provisional start first, +/// so it is not mistaken for one. +pub fn blank_substituted_for(provisional: Option<&str>, committed: &Url) -> bool { + committed.as_str() == "about:blank" + && provisional.is_some_and(|started| started != "about:blank") +} + pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { + tracing::debug!("[browser] tab {tab_id} page load {}: {url}", if started { "started" } else { "finished" }); let Some(registry) = app.try_state::<BrowserRegistry>() else { return; }; @@ -38,29 +51,158 @@ pub fn page_load(app: &AppHandle, tab_id: &str, url: &Url, started: bool) { ) }) }; - let state = registry.update_state(tab_id, |state| { + let state = registry.update(tab_id, |tab| { + let failed_address = (started && blank_substituted_for(tab.provisional_url.as_deref(), url)) + .then(|| tab.provisional_url.clone()) + .flatten(); + let substituted = failed_address.is_some(); + if started { + tab.provisional_url = None; + } + let state = &mut tab.state; state.url = url.to_string(); - state.loading = started; state.origin = origin_of(url); - if started { - state.error = None; - // The previous document's title must not label the new one; the - // toolbar falls back to the host until `title_changed` fires. - state.title.clear(); + if let Some(address) = failed_address { + // The engine gave up on the page and put nothing in its place: + // that is a failed load of the address that was asked for, and + // the empty document that committed is not worth a spinner. + state.loading = false; + state.error = Some(BrowserErrorInfo { + kind: BrowserErrorKind::Failed, + message: String::new(), + url: Some(address), + }); + } else { + state.loading = started; + if started { + state.error = None; + // The previous document's title must not label the new one; + // the toolbar falls back to the host until `title_changed` + // fires. + state.title.clear(); + } } if let Some((back, forward)) = history { state.can_go_back = back; state.can_go_forward = forward; } + (state.clone(), substituted) + }); + let Some((state, substituted)) = state else { + return; + }; + events::emit_state(app, &state); + if started && !substituted { + begin_load(app, tab_id); + } +} + +/// The engine started a main-frame navigation (WebKit's +/// `didStartProvisionalNavigation`, before any byte has arrived). This is the +/// earliest the tab knows where it is heading: a link click, a redirect chain +/// or a form post all announce themselves here, so `requested_url` follows +/// the page's own navigations and not only the address bar's. The failed-load +/// watcher is armed from here for page-initiated navigations; the commands +/// arm it themselves as well, and a second arming only retires the first. +pub fn navigation_started(app: &AppHandle, tab_id: &str, url: &Url) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let state = registry.update(tab_id, |tab| { + tab.provisional_url = Some(url.to_string()); + tab.state.requested_url = url.to_string(); + tab.state.loading = true; + tab.state.error = None; + tab.state.clone() }); if let Some(state) = state { events::emit_state(app, &state); } - if started { - begin_load(app, tab_id); + begin_load(app, tab_id); +} + +/// A navigation the engine reported as failed (a platform delegate callback, +/// where one exists — wry itself never reports failure). +#[derive(Debug, Clone, PartialEq)] +pub struct LoadFailure { + pub kind: BrowserErrorKind, + /// The platform's own description, in the system language. + pub message: String, + /// The address that failed, when the error names one. + pub url: Option<String>, + /// Failed before anything committed (nothing of the new page is showing) + /// rather than after (the page is up, a later part of the load broke). + pub provisional: bool, +} + +/// Classify a platform load error. `None` means "not a failure of the page": +/// a cancelled navigation (superseded by another, stopped by the user) and a +/// load the host itself redirected to a download or refused by policy both +/// end with an error code that is not the page's fault and must not paint an +/// error page. Domains and codes are Apple's (`NSURLErrorDomain`, +/// `WebKitErrorDomain`); other platforms map their own onto the same kinds. +pub fn classify_load_error(domain: &str, code: i64) -> Option<BrowserErrorKind> { + match domain { + "NSURLErrorDomain" => match code { + // NSURLErrorCancelled + -999 => None, + // NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed + -1003 | -1006 => Some(BrowserErrorKind::Dns), + // NSURLErrorSecureConnectionFailed … NSURLErrorClientCertificateRequired + -1206..=-1200 => Some(BrowserErrorKind::Tls), + _ => Some(BrowserErrorKind::Failed), + }, + // WebKitErrorFrameLoadInterruptedByPolicyChange: the policy delegate + // (our own navigation handler) cancelled the load, or it became a + // download. Both are handled where they happen. + "WebKitErrorDomain" if code == 102 => None, + _ => Some(BrowserErrorKind::Failed), + } +} + +/// Apply a reported failure. A provisional failure replaces the page with the +/// error page for the address that was asked for; a failure after commit +/// only stops the spinner — the document that committed stays, as in a +/// browser. Either way the load watcher, seeing `loading: false`, stands +/// down. +pub fn navigation_failed(app: &AppHandle, tab_id: &str, failure: LoadFailure) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let state = registry.update(tab_id, |tab| { + if failure.provisional { + tab.provisional_url = None; + } + let state = &mut tab.state; + state.loading = false; + if failure.provisional { + let url = failure + .url + .clone() + .filter(|u| !u.is_empty()) + .or_else(|| (!state.requested_url.is_empty()).then(|| state.requested_url.clone())) + .or_else(|| (!state.url.is_empty()).then(|| state.url.clone())); + state.error = Some(BrowserErrorInfo { + kind: failure.kind, + message: failure.message.clone(), + url, + }); + } + state.clone() + }); + if let Some(state) = state { + events::emit_state(app, &state); } } +/// A top-level navigation was refused (scheme not allowed, or a site rule). +/// Nothing changes in the tab; the status layer shows why the click did +/// nothing. +pub fn navigation_blocked(app: &AppHandle, tab_id: &str, url: &str, reason: NavigationBlockReason) { + tracing::info!("[browser] tab {tab_id} blocked navigation to {url} ({reason:?})"); + events::emit_navigation_blocked(app, tab_id, url, reason); +} + /// Arm the failed-load watcher for a navigation that is starting now. Called /// where a load is kicked off (open, address bar, adopted popup) as well as /// from `page_load`: wry's "started" is WebKit's `didCommitNavigation`, so a @@ -278,6 +420,40 @@ mod tests { assert_eq!(s.requested_url, ""); } + /// An empty document committed in place of the page that was started is + /// a refused load; a page that heads for `about:blank` itself is not. + #[test] + fn a_blank_commit_counts_as_failure_only_when_something_else_was_started() { + let blank = Url::parse("about:blank").unwrap(); + let page = Url::parse("http://127.0.0.1:1/").unwrap(); + assert!(blank_substituted_for(Some("http://127.0.0.1:1/"), &blank)); + assert!(!blank_substituted_for(Some("about:blank"), &blank)); + assert!(!blank_substituted_for(None, &blank)); + assert!(!blank_substituted_for(Some("http://127.0.0.1:1/"), &page)); + } + + /// Apple's codes, by kind — and the two that are NOT page failures. + #[test] + fn load_errors_classify_by_domain_and_code() { + use BrowserErrorKind::*; + assert_eq!(classify_load_error("NSURLErrorDomain", -1003), Some(Dns)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1006), Some(Dns)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1200), Some(Tls)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1202), Some(Tls)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1206), Some(Tls)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1004), Some(Failed)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1001), Some(Failed)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1009), Some(Failed)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1199), Some(Failed)); + assert_eq!(classify_load_error("NSURLErrorDomain", -1207), Some(Failed)); + // Superseded / stopped: not an error of the page. + assert_eq!(classify_load_error("NSURLErrorDomain", -999), None); + // Cancelled by our own policy handler, or became a download. + assert_eq!(classify_load_error("WebKitErrorDomain", 102), None); + assert_eq!(classify_load_error("WebKitErrorDomain", 101), Some(Failed)); + assert_eq!(classify_load_error("WKErrorDomain", 2), Some(Failed)); + } + #[test] fn origin_is_none_for_opaque_urls() { assert_eq!( diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs index 091e5a4120..6f8bc2028d 100644 --- a/src-tauri/src/browser/policy.rs +++ b/src-tauri/src/browser/policy.rs @@ -1,6 +1,12 @@ -//! Pure policy decisions for browser tabs. Everything here is a function of -//! its arguments so the tables in the unit tests are the specification. +//! Policy decisions for browser tabs: the scheme allow-lists, the per-site +//! rule table, and the administrator's policy file. Everything that decides +//! is a function of its arguments so the tables in the unit tests are the +//! specification; `BrowserPolicy` only holds the two rule lists. +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use serde::{Deserialize, Serialize}; use tauri::Url; /// Top-level navigation allow-list. Only real web pages may load in a tab: @@ -21,12 +27,388 @@ pub fn navigation_allowed(url: &Url) -> bool { } } +/// What a page may load into one of ITS OWN frames. Wider than the top-level +/// list: `about:srcdoc`, `data:` and `blob:` frames are opaque-origin content +/// a page composes itself (markdown previews, sandboxes, embeds) and reach +/// nothing the page could not reach already, while `file:` and the app's own +/// schemes stay out. The engine only asks about frame navigations, never +/// about images or fetches. +pub fn subframe_navigation_allowed(url: &Url) -> bool { + match url.scheme() { + "http" | "https" | "data" | "blob" => true, + "about" => matches!(url.as_str(), "about:blank" | "about:srcdoc"), + _ => false, + } +} + /// The initial URL handed to `browser_open_tab` must already be a web page; /// `about:blank` is accepted so an empty tab can be opened explicitly. pub fn open_url_allowed(url: &Url) -> bool { matches!(url.scheme(), "http" | "https") || url.as_str() == "about:blank" } +// --------------------------------------------------------------------------- +// Site rules +// --------------------------------------------------------------------------- + +/// What a matching site rule asks for. `Builtin` / `System` are routing +/// preferences the frontend's link decision applies; `Block` is enforced here +/// as well, on every navigation a tab attempts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HostRuleAction { + Builtin, + System, + Block, +} + +/// One row of the site-rule table. Same shape as `HostRule` in +/// `src/lib/browser/host-rules.ts`, which mirrors the matching below. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostRule { + /// A hostname, `*.suffix`, or `*`, optionally followed by `:port`. + pub pattern: String, + pub action: HostRuleAction, +} + +/// Longest a hostname can be (RFC 1035); anything longer is not a pattern. +const MAX_PATTERN_LEN: usize = 253 + 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum HostMatcher { + Any, + /// `*.example.com` — stored as `.example.com`. + Suffix(String), + Exact(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedPattern { + host: HostMatcher, + port: Option<u16>, +} + +/// Parse a pattern the way the frontend does (`parseHostRulePattern`); `None` +/// for anything that is not a pattern, which then never matches. +fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { + let trimmed = pattern.trim().to_ascii_lowercase(); + if trimmed.is_empty() || trimmed.len() > MAX_PATTERN_LEN { + return None; + } + let (host, port) = if let Some(rest) = trimmed.strip_prefix('[') { + // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows. + let close = rest.find(']')?; + let host = &rest[..close]; + let tail = &rest[close + 1..]; + let port = match tail.strip_prefix(':') { + Some(digits) => Some(digits), + None if tail.is_empty() => None, + None => return None, + }; + (host.to_string(), port.map(str::to_string)) + } else { + match trimmed.rsplit_once(':') { + Some((host, digits)) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => { + (host.to_string(), Some(digits.to_string())) + } + Some(_) => return None, + None => (trimmed.clone(), None), + } + }; + let port = match port { + Some(digits) => Some(digits.parse::<u16>().ok().filter(|p| *p > 0)?), + None => None, + }; + let host = if host == "*" { + HostMatcher::Any + } else if let Some(suffix) = host.strip_prefix("*.") { + if !valid_hostname(suffix) { + return None; + } + HostMatcher::Suffix(format!(".{suffix}")) + } else if valid_hostname(&host) || valid_ipv6(&host) { + HostMatcher::Exact(host) + } else { + return None; + }; + Some(ParsedPattern { host, port }) +} + +fn valid_hostname(host: &str) -> bool { + !host.is_empty() + && !host.starts_with('.') + && !host.ends_with('.') + && !host.contains("..") + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_') +} + +fn valid_ipv6(host: &str) -> bool { + host.contains(':') && host.bytes().all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.') +} + +/// Whether `pattern` is one the table accepts (the settings UI validates +/// with the same rule on its side). +pub fn valid_pattern(pattern: &str) -> bool { + parse_pattern(pattern).is_some() +} + +fn effective_port(url: &Url) -> Option<u16> { + url.port_or_known_default() +} + +impl ParsedPattern { + fn matches(&self, hostname: &str, port: Option<u16>) -> bool { + let host_ok = match &self.host { + HostMatcher::Any => true, + HostMatcher::Suffix(suffix) => hostname.ends_with(suffix.as_str()) && hostname.len() > suffix.len(), + HostMatcher::Exact(exact) => hostname == exact, + }; + host_ok && self.port.is_none_or(|wanted| port == Some(wanted)) + } + + /// Higher wins: an exact host over a wildcard, a longer wildcard suffix + /// over a shorter one, `*` last; a pinned port breaks a tie. + fn specificity(&self) -> (u8, usize, u8) { + let (kind, len) = match &self.host { + HostMatcher::Exact(host) => (2, host.len()), + HostMatcher::Suffix(suffix) => (1, suffix.len()), + HostMatcher::Any => (0, 0), + }; + (kind, len, u8::from(self.port.is_some())) + } +} + +/// The rule that applies to `url`: the most specific matching pattern, and +/// among equally specific ones the first listed. Unparsable patterns never +/// match. Same algorithm as `matchHostRule` on the frontend. +pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostRule> { + let hostname = url.host_str()?.trim_matches(|c| c == '[' || c == ']').to_ascii_lowercase(); + let port = effective_port(url); + let mut best: Option<(&HostRule, (u8, usize, u8))> = None; + for rule in rules { + let Some(parsed) = parse_pattern(&rule.pattern) else { + continue; + }; + if !parsed.matches(&hostname, port) { + continue; + } + let score = parsed.specificity(); + if best.as_ref().is_none_or(|(_, current)| score > *current) { + best = Some((rule, score)); + } + } + best.map(|(rule, _)| rule) +} + +// --------------------------------------------------------------------------- +// The administrator's policy file +// --------------------------------------------------------------------------- + +/// Settings an administrator fixes for every user of the machine. Read once +/// at startup from `managed_policy_path()`; anything set here is shown in the +/// settings section as read-only and consulted before the user's own rules. +#[derive(Debug, Clone, PartialEq)] +pub struct ManagedPolicy { + /// `false` turns the built-in browser off: every link goes to the system + /// browser and `browser_open_tab` refuses. + pub browser_enabled: bool, + pub host_rules: Vec<HostRule>, + /// Where the policy came from, for the settings section. + pub source: Option<PathBuf>, +} + +impl Default for ManagedPolicy { + fn default() -> Self { + Self { + browser_enabled: true, + host_rules: Vec::new(), + source: None, + } + } +} + +/// On-disk shape: `{ "browser": { "enabled": bool, "hostRules": [...] } }`. +/// Every key is optional and unknown keys are ignored, so the file can grow +/// other sections later without breaking older builds. +#[derive(Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ManagedPolicyFile { + browser: ManagedBrowserSection, +} + +#[derive(Debug, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ManagedBrowserSection { + enabled: bool, + host_rules: Vec<serde_json::Value>, +} + +impl Default for ManagedBrowserSection { + fn default() -> Self { + Self { + enabled: true, + host_rules: Vec::new(), + } + } +} + +/// `CODEG_POLICY_FILE` when set (tests, unusual deployments), else the +/// machine-wide location for the platform. The file is optional. +pub fn managed_policy_path() -> PathBuf { + if let Some(explicit) = std::env::var_os("CODEG_POLICY_FILE") { + return PathBuf::from(explicit); + } + if cfg!(target_os = "macos") { + PathBuf::from("/Library/Application Support/codeg/policy.json") + } else if cfg!(target_os = "windows") { + let base = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + base.join("codeg").join("policy.json") + } else { + PathBuf::from("/etc/codeg/policy.json") + } +} + +/// Parse a policy file's contents. Malformed rules are dropped one by one +/// (and logged) rather than failing the whole file, so a typo in one line +/// does not silently lift every other restriction. +pub fn parse_managed_policy(raw: &str) -> Result<ManagedPolicy, String> { + let file: ManagedPolicyFile = serde_json::from_str(raw).map_err(|e| e.to_string())?; + let mut host_rules = Vec::new(); + for value in file.browser.host_rules { + match serde_json::from_value::<HostRule>(value.clone()) { + Ok(rule) if valid_pattern(&rule.pattern) => host_rules.push(rule), + Ok(rule) => tracing::warn!("[browser] policy: ignoring rule with invalid pattern {:?}", rule.pattern), + Err(err) => tracing::warn!("[browser] policy: ignoring malformed rule {value}: {err}"), + } + } + Ok(ManagedPolicy { + browser_enabled: file.browser.enabled, + host_rules, + source: None, + }) +} + +/// Read the policy at `path`; `None` when there is no file. An unreadable or +/// malformed file is reported and treated as absent — a broken policy must +/// not lock everyone out, and must not be mistaken for a permissive one +/// either, which is why it is logged at error level. +pub fn read_managed_policy(path: &Path) -> Option<ManagedPolicy> { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + tracing::error!("[browser] policy file {} is unreadable: {err}", path.display()); + return None; + } + }; + match parse_managed_policy(&raw) { + Ok(mut policy) => { + policy.source = Some(path.to_path_buf()); + tracing::info!( + "[browser] policy loaded from {} (enabled: {}, {} rule(s))", + path.display(), + policy.browser_enabled, + policy.host_rules.len() + ); + Some(policy) + } + Err(err) => { + tracing::error!("[browser] policy file {} is malformed: {err}", path.display()); + None + } + } +} + +/// What the settings section shows about the policy in force. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserPolicyStatus { + pub enabled: bool, + pub managed_rules: Vec<HostRule>, + pub managed_source: Option<String>, +} + +/// The rules in force: the administrator's (fixed for the process) and the +/// user's (pushed by the frontend whenever the preference changes). Managed +/// state; cheap to read from any hook. +pub struct BrowserPolicy { + managed: ManagedPolicy, + user_rules: RwLock<Vec<HostRule>>, +} + +impl Default for BrowserPolicy { + fn default() -> Self { + Self::with_managed(ManagedPolicy::default()) + } +} + +impl BrowserPolicy { + /// Read the administrator's policy file (if any) and start with no user + /// rules; the frontend pushes those once it is up. + pub fn load() -> Self { + let managed = read_managed_policy(&managed_policy_path()).unwrap_or_default(); + Self::with_managed(managed) + } + + pub fn with_managed(managed: ManagedPolicy) -> Self { + Self { + managed, + user_rules: RwLock::new(Vec::new()), + } + } + + pub fn enabled(&self) -> bool { + self.managed.browser_enabled + } + + /// Replace the user's rules. Invalid patterns are dropped here too, so a + /// hand-edited preference cannot make a rule that never matches look + /// like protection. + pub fn set_user_rules(&self, rules: Vec<HostRule>) { + let rules: Vec<HostRule> = rules + .into_iter() + .filter(|rule| valid_pattern(&rule.pattern)) + .collect(); + *self.user_rules.write().unwrap_or_else(|p| p.into_inner()) = rules; + } + + pub fn user_rules(&self) -> Vec<HostRule> { + self.user_rules.read().unwrap_or_else(|p| p.into_inner()).clone() + } + + /// The rule for `url`: the administrator's table first (it wins whatever + /// the user wrote), then the user's. + pub fn rule_for(&self, url: &Url) -> Option<HostRule> { + if let Some(rule) = match_host_rule(&self.managed.host_rules, url) { + return Some(rule.clone()); + } + let user = self.user_rules.read().unwrap_or_else(|p| p.into_inner()); + match_host_rule(&user, url).cloned() + } + + /// A `block` rule applies to `url`. + pub fn blocked(&self, url: &Url) -> bool { + self.rule_for(url) + .is_some_and(|rule| rule.action == HostRuleAction::Block) + } + + pub fn status(&self) -> BrowserPolicyStatus { + BrowserPolicyStatus { + enabled: self.managed.browser_enabled, + managed_rules: self.managed.host_rules.clone(), + managed_source: self + .managed + .source + .as_ref() + .map(|p| p.display().to_string()), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -35,6 +417,13 @@ mod tests { Url::parse(s).unwrap() } + fn rule(pattern: &str, action: HostRuleAction) -> HostRule { + HostRule { + pattern: pattern.to_string(), + action, + } + } + #[test] fn navigation_allow_list() { for ok in [ @@ -52,6 +441,7 @@ mod tests { "javascript:alert(1)", "data:text/html,<b>x</b>", "about:config", + "about:srcdoc", "blob:null/abc", "blob:file:///x", "codeg-doc://grant/index.html", @@ -62,6 +452,25 @@ mod tests { } } + /// A page's own frames may hold the opaque-origin content pages compose + /// (srcdoc previews, data: sandboxes); the filesystem and app schemes + /// stay out of frames too. + #[test] + fn subframes_may_hold_opaque_content_but_not_local_schemes() { + for ok in [ + "about:srcdoc", + "about:blank", + "data:text/html,<b>x</b>", + "blob:https://example.com/x", + "https://embed.example/", + ] { + assert!(subframe_navigation_allowed(&u(ok)), "{ok}"); + } + for bad in ["file:///etc/passwd", "tauri://localhost/", "codeg-doc://g/x", "about:config"] { + assert!(!subframe_navigation_allowed(&u(bad)), "{bad}"); + } + } + #[test] fn open_url_is_stricter_than_navigation() { assert!(open_url_allowed(&u("https://example.com"))); @@ -69,4 +478,176 @@ mod tests { assert!(!open_url_allowed(&u("blob:https://example.com/x"))); assert!(!open_url_allowed(&u("file:///x"))); } + + // -- site rules --------------------------------------------------------- + + #[test] + fn pattern_grammar() { + for ok in [ + "example.com", + "EXAMPLE.com", + " example.com ", + "*.example.com", + "*", + "localhost:3000", + "*.corp.example:8443", + "[::1]:3000", + "[::1]", + "127.0.0.1", + "10.0.0.1:8080", + "*:443", + ] { + assert!(valid_pattern(ok), "{ok:?} should parse"); + } + for bad in [ + "", + " ", + "https://example.com", + "example.com/path", + "example.com:", + "example.com:0", + "example.com:70000", + "example.com:80a", + "*.", + "*example.com", + "a b.com", + ".example.com", + "example..com", + "[::1", + "[::1]x", + "*.*", + ] { + assert!(!valid_pattern(bad), "{bad:?} should not parse"); + } + } + + /// Mirror of the frontend's `matchHostRule` table. + #[test] + fn wildcard_semantics() { + let block = [rule("*.example.com", HostRuleAction::Block)]; + assert!(match_host_rule(&block, &u("https://a.example.com/")).is_some()); + assert!(match_host_rule(&block, &u("https://a.b.example.com/")).is_some()); + assert!(match_host_rule(&block, &u("https://example.com/")).is_none()); + assert!(match_host_rule(&block, &u("https://notexample.com/")).is_none()); + + let any = [rule("*", HostRuleAction::System)]; + assert!(match_host_rule(&any, &u("http://x/")).is_some()); + + let v6 = [rule("[::1]:3000", HostRuleAction::Builtin)]; + assert!(match_host_rule(&v6, &u("http://[::1]:3000/")).is_some()); + assert!(match_host_rule(&v6, &u("http://[::1]:3001/")).is_none()); + + // Ports pin a rule and compare against the scheme's default. + let https_default = [rule("example.com:443", HostRuleAction::Builtin)]; + assert!(match_host_rule(&https_default, &u("https://EXAMPLE.com/")).is_some()); + let http_only = [rule("example.com:80", HostRuleAction::Builtin)]; + assert!(match_host_rule(&http_only, &u("https://example.com/")).is_none()); + assert!(match_host_rule(&http_only, &u("http://example.com/")).is_some()); + + // An unparsable pattern never matches, and never panics. + let junk = [rule("https://example.com", HostRuleAction::Block)]; + assert!(match_host_rule(&junk, &u("https://example.com/")).is_none()); + } + + #[test] + fn most_specific_rule_wins_regardless_of_order() { + let rules = [ + rule("*", HostRuleAction::System), + rule("*.corp.example", HostRuleAction::Builtin), + rule("*.corp.example:8443", HostRuleAction::System), + rule("sso.corp.example", HostRuleAction::Block), + rule("*.sso.corp.example", HostRuleAction::Builtin), + ]; + let action = |url: &str| match_host_rule(&rules, &u(url)).map(|r| r.action); + assert_eq!(action("https://sso.corp.example/"), Some(HostRuleAction::Block)); + assert_eq!(action("https://sso.corp.example:8443/"), Some(HostRuleAction::Block)); + assert_eq!(action("https://wiki.corp.example:8443/"), Some(HostRuleAction::System)); + assert_eq!(action("https://wiki.corp.example/"), Some(HostRuleAction::Builtin)); + assert_eq!(action("https://a.sso.corp.example/"), Some(HostRuleAction::Builtin)); + assert_eq!(action("https://elsewhere.example/"), Some(HostRuleAction::System)); + // Equal specificity: the first listed. + let tie = [ + rule("dup.example", HostRuleAction::Builtin), + rule("dup.example", HostRuleAction::Block), + ]; + assert_eq!( + match_host_rule(&tie, &u("https://dup.example/")).map(|r| r.action), + Some(HostRuleAction::Builtin) + ); + } + + #[test] + fn managed_rules_beat_user_rules_and_invalid_user_rules_are_dropped() { + let policy = BrowserPolicy::with_managed(ManagedPolicy { + browser_enabled: true, + host_rules: vec![rule("*.internal.example", HostRuleAction::Block)], + source: None, + }); + policy.set_user_rules(vec![ + rule("wiki.internal.example", HostRuleAction::Builtin), // more specific, still loses + rule("blocked.example", HostRuleAction::Block), + rule("not a pattern", HostRuleAction::Block), + ]); + assert!(policy.blocked(&u("https://wiki.internal.example/"))); + assert!(policy.blocked(&u("https://blocked.example/x"))); + assert!(!policy.blocked(&u("https://example.com/"))); + assert_eq!(policy.user_rules().len(), 2); + assert!(policy.enabled()); + let status = policy.status(); + assert_eq!(status.managed_rules.len(), 1); + assert!(status.managed_source.is_none()); + } + + #[test] + fn policy_file_is_tolerant_but_never_permissive_by_accident() { + let parsed = parse_managed_policy( + r#"{ + "browser": { + "enabled": false, + "hostRules": [ + { "pattern": "*.corp.example", "action": "block" }, + { "pattern": "not a pattern", "action": "block" }, + { "pattern": "x.example", "action": "explode" }, + "junk" + ] + }, + "other": { "future": true } + }"#, + ) + .unwrap(); + assert!(!parsed.browser_enabled); + assert_eq!(parsed.host_rules, vec![rule("*.corp.example", HostRuleAction::Block)]); + + // Missing keys mean "no restriction", not "off". + let empty = parse_managed_policy("{}").unwrap(); + assert!(empty.browser_enabled); + assert!(empty.host_rules.is_empty()); + + // Not JSON at all: an error, which `read_managed_policy` logs and + // treats as no file. + assert!(parse_managed_policy("{ nope").is_err()); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.json"); + assert!(read_managed_policy(&path).is_none()); + std::fs::write(&path, "{ nope").unwrap(); + assert!(read_managed_policy(&path).is_none()); + std::fs::write(&path, r#"{"browser":{"hostRules":[{"pattern":"a.example","action":"system"}]}}"#).unwrap(); + let loaded = read_managed_policy(&path).unwrap(); + assert_eq!(loaded.source.as_deref(), Some(path.as_path())); + assert_eq!(loaded.host_rules.len(), 1); + } + + #[test] + fn wire_names() { + let json = serde_json::to_value(BrowserPolicyStatus { + enabled: true, + managed_rules: vec![rule("a.example", HostRuleAction::Block)], + managed_source: Some("/etc/codeg/policy.json".into()), + }) + .unwrap(); + assert_eq!(json["managedRules"][0]["action"], "block"); + assert_eq!(json["managedSource"], "/etc/codeg/policy.json"); + let rule: HostRule = serde_json::from_str(r#"{"pattern":"*","action":"builtin"}"#).unwrap(); + assert_eq!(rule.action, HostRuleAction::Builtin); + } } diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 68f649662f..75feebbe13 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -45,6 +45,16 @@ pub struct BrowserTab { /// rather than on the URL keeps a redirected download working while a /// later, genuinely failing navigation still reports itself. pub download_seq: Option<u64>, + /// Bumped by every `set_visible` request. A hide that first captures a + /// freeze frame is asynchronous; when it comes back it applies only if no + /// newer request has been made meanwhile — otherwise a quick close of the + /// overlay would be followed by a stale hide. + pub visible_seq: u64, + /// URL of the main-frame navigation the engine reported as started and + /// has neither committed nor failed yet (platforms with a navigation + /// delegate only). Lets a commit of `about:blank` in its place be + /// recognised for what it is: a load the engine refused silently. + pub provisional_url: Option<String>, pub gestures: VecDeque<GestureRecord>, } @@ -64,6 +74,8 @@ impl BrowserTab { devtools, load_seq: 0, download_seq: None, + visible_seq: 0, + provisional_url: None, gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), } } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 7433d3a073..8f6aa4793b 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -6,24 +6,30 @@ //! `WKContentWorld` needs macOS 11; older systems fall back to the page world //! (reported as `ChannelKind::Legacy`). -use std::cell::RefCell; -use std::collections::HashSet; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; use std::ptr::NonNull; +use std::sync::Arc; use block2::RcBlock; use objc2::rc::Retained; -use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject}; +use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject, Sel}; use objc2::{define_class, msg_send, sel, DeclaredClass, MainThreadMarker, MainThreadOnly}; -use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; -use objc2_foundation::{ns_string, NSArray, NSDate, NSDictionary, NSError, NSProcessInfo, NSString, NSUUID}; +use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage, NSImageCompressionFactor}; +use objc2_foundation::{ + ns_string, NSArray, NSDate, NSDictionary, NSError, NSNumber, NSProcessInfo, NSString, NSURL, + NSURLErrorFailingURLErrorKey, NSUUID, +}; use objc2_web_kit::{ - WKContentWorld, WKFindConfiguration, WKFindResult, WKScriptMessage, WKScriptMessageHandler, + WKContentWorld, WKFindConfiguration, WKFindResult, WKNavigation, WKNavigationAction, + WKNavigationActionPolicy, WKNavigationDelegate, WKScriptMessage, WKScriptMessageHandler, WKSnapshotConfiguration, WKUserContentController, WKUserScript, WKUserScriptInjectionTime, - WKWebViewConfiguration, WKWebsiteDataRecord, WKWebsiteDataStore, + WKWebView, WKWebViewConfiguration, WKWebsiteDataRecord, WKWebsiteDataStore, }; use tauri_runtime_wry::wry::{self, WebViewExtMacOS}; use super::super::channel::MessageSink; +use super::super::hooks::{classify_load_error, LoadFailure}; use super::super::profile::{BrowserProxy, ProxyScheme, DEFAULT_DATA_STORE_IDENTIFIER}; pub const WORLD_NAME: &str = "codeg"; @@ -190,6 +196,60 @@ pub fn eval_in_world( Ok(()) } +/// Viewport snapshot as JPEG: `(bytes, pixel width, pixel height)`. Built for +/// the freeze frame a placeholder shows while its surface is hidden under an +/// overlay, so it takes the frame as displayed now (`afterScreenUpdates: +/// false`) and encodes as JPEG through AppKit — a 5-megapixel PNG would take +/// longer to encode than the overlay's own open animation. +pub fn snapshot_jpeg( + webview: &wry::WebView, + quality: f64, + callback: impl Fn(Result<(Vec<u8>, u32, u32), String>) + Send + 'static, +) -> Result<(), String> { + let mtm = mtm()?; + let wk = webview.webview(); + let block = RcBlock::<dyn Fn(*mut NSImage, *mut NSError)>::new( + move |image: *mut NSImage, error: *mut NSError| { + // SAFETY: WebKit passes valid or null pointers; we only read. + let outcome = unsafe { + if !error.is_null() { + Err((*error).localizedDescription().to_string()) + } else if image.is_null() { + Err("snapshot returned no image".to_string()) + } else { + encode_jpeg(&*image, quality, mtm) + } + }; + callback(outcome); + }, + ); + // SAFETY: main thread, live webview. + unsafe { + let config = WKSnapshotConfiguration::new(mtm); + config.setAfterScreenUpdates(false); + wk.takeSnapshotWithConfiguration_completionHandler(Some(&config), &block); + } + Ok(()) +} + +/// # Safety +/// Main thread, live image. +unsafe fn encode_jpeg(image: &NSImage, quality: f64, mtm: MainThreadMarker) -> Result<(Vec<u8>, u32, u32), String> { + let cg = image + .CGImageForProposedRect_context_hints(std::ptr::null_mut(), None, None) + .ok_or_else(|| "snapshot has no bitmap".to_string())?; + let rep = NSBitmapImageRep::initWithCGImage(mtm.alloc(), &cg); + let width = u32::try_from(rep.pixelsWide()).unwrap_or(0); + let height = u32::try_from(rep.pixelsHigh()).unwrap_or(0); + let factor = NSNumber::new_f64(quality); + let factor_object: &AnyObject = &factor; + let properties = NSDictionary::from_slices(&[NSImageCompressionFactor], &[factor_object]); + let data = rep + .representationUsingType_properties(NSBitmapImageFileType::JPEG, &properties) + .ok_or_else(|| "jpeg encoding failed".to_string())?; + Ok((data.to_vec(), width, height)) +} + /// Viewport snapshot as PNG bytes. pub fn snapshot_png( webview: &wry::WebView, @@ -351,6 +411,196 @@ pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { }) } +// --------------------------------------------------------------------------- +// Navigation delegate: what wry does not report +// --------------------------------------------------------------------------- +// +// wry installs its own `WKNavigationDelegate` and surfaces two of its +// callbacks (commit, finish). A tab needs three more: the provisional start +// (where a page-initiated navigation is heading), the failures (which kind, +// and at once rather than when a poll notices the spinner stopped), and, for +// the policy decision wry does forward, whether the action is for the main +// frame. Rather than re-implementing wry's delegate — its handlers reach into +// private state — the tab's `WKWebView` gets a wrapper: it answers the +// callbacks it cares about and forwards every other selector to wry's object +// (`forwardingTargetForSelector:`), which stays alive inside wry's +// `WebView` for as long as the tab does. `respondsToSelector:` is answered +// from both, so WebKit sees exactly the optional methods wry implements plus +// ours. The wrapper is dropped together with the webview: keeping it longer +// would keep wry's delegate, and through it the `WKWebView`, alive. + +/// Events the wrapper reports, on the main thread. +pub enum NavigationEvent { + /// A main-frame navigation started; the URL it is heading for. + Started(String), + Failed(LoadFailure), +} + +pub type NavigationSink = Arc<dyn Fn(NavigationEvent) + Send + Sync>; + +thread_local! { + /// Wrappers by `WKWebView` pointer, kept alive here (`navigationDelegate` + /// is a weak property). + static NAV_DELEGATES: RefCell<HashMap<usize, Retained<CodegNavigationDelegate>>> = + RefCell::new(HashMap::new()); + /// Whether the navigation action currently being decided targets the main + /// frame. Set around the forward to wry, whose synchronous call into the + /// navigation handler is the only place the host learns about the action + /// — and wry's handler signature carries the URL alone. + static CURRENT_ACTION_MAIN_FRAME: Cell<Option<bool>> = const { Cell::new(None) }; +} + +/// Inside a navigation handler: does the action being decided target the +/// main frame? `None` outside a decision (other platforms, or a call from +/// elsewhere), which callers treat as "main frame" — the strict reading. +pub fn current_navigation_is_main_frame() -> Option<bool> { + CURRENT_ACTION_MAIN_FRAME.with(|flag| flag.get()) +} + +pub struct NavigationDelegateIvars { + inner: Retained<ProtocolObject<dyn WKNavigationDelegate>>, + sink: NavigationSink, +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[ivars = NavigationDelegateIvars] + pub struct CodegNavigationDelegate; + + unsafe impl NSObjectProtocol for CodegNavigationDelegate {} + + impl CodegNavigationDelegate { + #[unsafe(method(respondsToSelector:))] + fn responds_to_selector(&self, selector: Sel) -> bool { + self.class().responds_to(selector) || self.ivars().inner.respondsToSelector(selector) + } + + #[unsafe(method(forwardingTargetForSelector:))] + fn forwarding_target_for_selector(&self, _selector: Sel) -> *mut AnyObject { + Retained::as_ptr(&self.ivars().inner) as *mut AnyObject + } + } + + unsafe impl WKNavigationDelegate for CodegNavigationDelegate { + #[unsafe(method(webView:decidePolicyForNavigationAction:decisionHandler:))] + fn decide_policy( + &self, + webview: &WKWebView, + action: &WKNavigationAction, + handler: &block2::Block<dyn Fn(WKNavigationActionPolicy)>, + ) { + // SAFETY: WebKit hands us live objects on the main thread. + let main_frame = unsafe { action.targetFrame().map(|frame| frame.isMainFrame()) }; + // No target frame = a new window; the strict reading applies. + CURRENT_ACTION_MAIN_FRAME.with(|flag| flag.set(Some(main_frame.unwrap_or(true)))); + let inner = &self.ivars().inner; + // SAFETY: forwarding the exact selector and arguments WebKit gave + // us to the delegate that implements it. + unsafe { + let _: () = msg_send![ + &**inner, + webView: webview, + decidePolicyForNavigationAction: action, + decisionHandler: handler + ]; + } + CURRENT_ACTION_MAIN_FRAME.with(|flag| flag.set(None)); + } + + #[unsafe(method(webView:didStartProvisionalNavigation:))] + fn did_start_provisional(&self, webview: &WKWebView, _navigation: Option<&WKNavigation>) { + // `URL` is the active URL: the provisional one while a load is in + // flight, so this is where the navigation is heading. + // SAFETY: main thread, live webview. + let url = unsafe { webview.URL().and_then(|u| u.absoluteString()) }.map(|s| s.to_string()); + tracing::debug!("[browser] navigation started: {url:?}"); + if let Some(url) = url { + (self.ivars().sink)(NavigationEvent::Started(url)); + } + } + + #[unsafe(method(webView:didFailProvisionalNavigation:withError:))] + fn did_fail_provisional(&self, _webview: &WKWebView, _navigation: Option<&WKNavigation>, error: &NSError) { + self.report_failure(error, true); + } + + #[unsafe(method(webView:didFailNavigation:withError:))] + fn did_fail(&self, _webview: &WKWebView, _navigation: Option<&WKNavigation>, error: &NSError) { + self.report_failure(error, false); + } + } +); + +impl CodegNavigationDelegate { + fn new( + inner: Retained<ProtocolObject<dyn WKNavigationDelegate>>, + sink: NavigationSink, + mtm: MainThreadMarker, + ) -> Retained<Self> { + let this = mtm + .alloc::<Self>() + .set_ivars(NavigationDelegateIvars { inner, sink }); + // SAFETY: plain NSObject init. + unsafe { msg_send![super(this), init] } + } + + fn report_failure(&self, error: &NSError, provisional: bool) { + let domain = error.domain().to_string(); + let code = i64::try_from(error.code()).unwrap_or(i64::MAX); + let kind = classify_load_error(&domain, code); + tracing::debug!( + "[browser] navigation failed (provisional: {provisional}): {domain} {code} -> {kind:?}: {}", + error.localizedDescription() + ); + let Some(kind) = kind else { + return; + }; + // SAFETY: main thread; the dictionary and its values are live. + let url = unsafe { + error + .userInfo() + .objectForKey(NSURLErrorFailingURLErrorKey) + .and_then(|value| value.downcast::<NSURL>().ok()) + .and_then(|url| url.absoluteString()) + .map(|s| s.to_string()) + }; + (self.ivars().sink)(NavigationEvent::Failed(LoadFailure { + kind, + message: error.localizedDescription().to_string(), + url, + provisional, + })); + } +} + +/// Wrap the webview's navigation delegate. Idempotent per webview. +pub fn install_navigation_delegate(webview: &wry::WebView, sink: NavigationSink) -> Result<(), String> { + let mtm = mtm()?; + let wk = webview.webview(); + let key = Retained::as_ptr(&wk) as usize; + let installed = NAV_DELEGATES.with(|map| map.borrow().contains_key(&key)); + if installed { + return Ok(()); + } + // SAFETY: main thread, live webview. + let inner = unsafe { wk.navigationDelegate() } + .ok_or_else(|| "the webview has no navigation delegate to wrap".to_string())?; + let delegate = CodegNavigationDelegate::new(inner, sink, mtm); + // SAFETY: main thread; the wrapper is retained in `NAV_DELEGATES` below, + // which is what keeps the weak `navigationDelegate` valid. + unsafe { wk.setNavigationDelegate(Some(ProtocolObject::from_ref(&*delegate))) }; + NAV_DELEGATES.with(|map| map.borrow_mut().insert(key, delegate)); + Ok(()) +} + +/// Drop the wrapper of a webview that is going away (call before the wry +/// `WebView` is dropped, on the main thread). +pub fn forget_navigation_delegate(webview: &wry::WebView) { + let key = webview_pointer(webview); + NAV_DELEGATES.with(|map| map.borrow_mut().remove(&key)); +} + // --------------------------------------------------------------------------- // Profile: the tabs' own data store and its proxy // --------------------------------------------------------------------------- diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index f115cebe85..4f64498776 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -248,7 +248,7 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { } "browser_set_visible" => { let owner = owner()?; - browser_commands::set_visible_core( + let frame = browser_commands::set_visible_core( &owner, ®istry, &str_arg(cmd, "tab_id")?, @@ -256,9 +256,39 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { cmd.get("handoff_focus") .and_then(Value::as_bool) .unwrap_or(false), + cmd.get("freeze").and_then(Value::as_bool).unwrap_or(false), ) + .await .map_err(err_string)?; - Ok(Value::Null) + // The frame itself is large; report its shape, and write it out + // when asked so a run can look at it. + match frame { + Some(frame) => { + if let Some(path) = cmd.get("frame_path").and_then(Value::as_str) { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&frame.data) + .map_err(err_string)?; + std::fs::write(path, bytes).map_err(err_string)?; + } + Ok(json!({ + "frame": { "mime": frame.mime, "width": frame.width, "height": frame.height, "base64Len": frame.data.len() } + })) + } + None => Ok(json!({ "frame": Value::Null })), + } + } + "browser_set_host_rules" => { + let rules: Vec<crate::browser::policy::HostRule> = + serde_json::from_value(cmd.get("rules").cloned().unwrap_or(json!([]))) + .map_err(|e| format!("bad rules: {e}"))?; + let policy = app.state::<crate::browser::policy::BrowserPolicy>(); + policy.set_user_rules(rules); + Ok(json!({ "userRules": policy.user_rules().len() })) + } + "browser_capabilities" => { + let policy = app.state::<crate::browser::policy::BrowserPolicy>(); + Ok(serde_json::to_value(browser_commands::capabilities(&policy)).map_err(err_string)?) } "browser_navigate" => { let state = browser_commands::navigate_core( diff --git a/src-tauri/src/browser/surface.rs b/src-tauri/src/browser/surface.rs index a3e7e91391..816f93af6d 100644 --- a/src-tauri/src/browser/surface.rs +++ b/src-tauri/src/browser/surface.rs @@ -183,6 +183,18 @@ impl BrowserSurface { window: |_w| { let _ = callback; Err(SurfaceError("snapshots for owned windows land with the platform shims".into())) }) } + /// The frame as displayed now, JPEG-encoded (for the freeze frame shown + /// while a surface is hidden under an overlay). Embedded surfaces only. + pub fn snapshot_jpeg( + &self, + quality: f64, + callback: impl Fn(Result<(Vec<u8>, u32, u32), String>) + Send + 'static, + ) -> Result<(), SurfaceError> { + per_surface!(self, + child: |c| Ok(c.snapshot_jpeg(quality, callback)?), + window: |_w| { let _ = (quality, callback); Err(SurfaceError("snapshots for owned windows land with the platform shims".into())) }) + } + pub fn go_back(&self) -> Result<(), SurfaceError> { per_surface!(self, child: |c| Ok(c.go_back()?), diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 64963c470b..c98d835579 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -38,12 +38,12 @@ use super::channel::{self, MessageSink}; use super::profile; use super::events; use super::hooks; -use super::policy; +use super::policy::{self, BrowserPolicy}; use super::registry::{BrowserRegistry, BrowserTab}; use super::surface::BrowserSurface; use super::types::{ Bounds, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, ChannelKind, - PopupPresentation, SurfaceKind, + NavigationBlockReason, PopupPresentation, SurfaceKind, }; #[cfg(target_os = "macos")] use super::shim::macos as shim; @@ -125,6 +125,63 @@ fn message_sink(app: &AppHandle) -> MessageSink { }) } +/// Where the platform delegate's navigation events go: the registry, via +/// the hooks (main thread). +#[cfg(target_os = "macos")] +fn navigation_sink(app: &AppHandle, tab_id: &str) -> shim::NavigationSink { + let app = app.clone(); + let tab_id = tab_id.to_string(); + Arc::new(move |event| match event { + shim::NavigationEvent::Started(url) => { + if let Ok(url) = Url::parse(&url) { + hooks::navigation_started(&app, &tab_id, &url); + } + } + shim::NavigationEvent::Failed(failure) => hooks::navigation_failed(&app, &tab_id, failure), + }) +} + +/// Main thread only. Wrap the engine's navigation delegate so failures and +/// provisional starts reach the registry. Not fatal when it cannot be done: +/// the load watcher still notices a failed load, only later and untyped. +#[cfg(target_os = "macos")] +fn attach_navigation_delegate(app: &AppHandle, tab_id: &str, webview: &wry::WebView) { + if let Err(err) = shim::install_navigation_delegate(webview, navigation_sink(app, tab_id)) { + tracing::warn!( + "[browser] tab {tab_id}: navigation delegate not installed ({err}); failures are detected by polling" + ); + } +} + +#[cfg(not(target_os = "macos"))] +fn attach_navigation_delegate(_app: &AppHandle, _tab_id: &str, _webview: &wry::WebView) {} + +/// Inside a navigation handler: is the action being decided for the main +/// frame? Where the platform cannot say, the strict answer. +fn current_navigation_is_main_frame() -> bool { + #[cfg(target_os = "macos")] + { + shim::current_navigation_is_main_frame().unwrap_or(true) + } + #[cfg(not(target_os = "macos"))] + { + true + } +} + +/// Main thread only: forget a tab's webview together with everything hung +/// on it. `true` when there was one. +fn drop_surface(id: &str) -> bool { + SURFACES.with(|s| { + let removed = s.borrow_mut().remove(id); + #[cfg(target_os = "macos")] + if let Some(webview) = &removed { + shim::forget_navigation_delegate(webview); + } + removed.is_some() + }) +} + #[derive(Clone, Debug)] pub struct ChildHandle { tab_id: String, @@ -293,6 +350,26 @@ impl ChildHandle { } } + /// The frame as displayed now, JPEG-encoded, for the freeze frame. + pub fn snapshot_jpeg( + &self, + quality: f64, + callback: impl Fn(Result<(Vec<u8>, u32, u32), String>) + Send + 'static, + ) -> Result<(), ChildError> { + #[cfg(target_os = "macos")] + { + self.with(move |wv| shim::snapshot_jpeg(wv, quality, callback))? + .map_err(ChildError::Op) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (quality, callback); + Err(ChildError::Op( + "snapshots are not implemented on this platform yet".into(), + )) + } + } + /// Highlight the next / previous match of `query`; the callback gets /// whether anything matched. pub fn find( @@ -413,9 +490,7 @@ impl ChildHandle { /// native view from the window when the `WebView` drops). pub fn close(&self) -> Result<(), ChildError> { let id = self.tab_id.clone(); - let removed = run_on_main(&self.app, move || { - SURFACES.with(|s| s.borrow_mut().remove(&id)).is_some() - })?; + let removed = run_on_main(&self.app, move || drop_surface(&id))?; if removed { Ok(()) } else { @@ -515,11 +590,35 @@ fn configure_child<'a>( .with_devtools(devtools) .with_hotkeys_zoom(true) .with_navigation_handler(move |url| { - let allowed = Url::parse(&url) - .map(|u| policy::navigation_allowed(&u)) - .unwrap_or(false); - if !allowed { - tracing::info!("[browser] tab {nav_id} blocked navigation to {url}"); + let Ok(parsed) = Url::parse(&url) else { + tracing::info!("[browser] tab {nav_id} blocked unparsable navigation {url:?}"); + return false; + }; + // The engine asks about every frame's navigation; only the + // top-level one gets the strict list and a notice when refused. + let main_frame = current_navigation_is_main_frame(); + let scheme_ok = if main_frame { + policy::navigation_allowed(&parsed) + } else { + policy::subframe_navigation_allowed(&parsed) + }; + if !scheme_ok { + if main_frame { + hooks::navigation_blocked(&nav_app, &nav_id, &url, NavigationBlockReason::Scheme); + } else { + tracing::debug!("[browser] tab {nav_id} blocked frame navigation to {url}"); + } + return false; + } + // A `block` site rule applies to every frame: a page must not be + // able to load a blocked host by embedding it. + if nav_app + .try_state::<BrowserPolicy>() + .is_some_and(|policy| policy.blocked(&parsed)) + { + if main_frame { + hooks::navigation_blocked(&nav_app, &nav_id, &url, NavigationBlockReason::HostRule); + } return false; } // ⌘/Ctrl-click on a plain anchor: the page did not prevent the @@ -627,6 +726,7 @@ pub fn create( let label = label.to_string(); run_on_main(&app.clone(), move || -> Result<(), String> { let webview = build_child(&app, &owner, &id, &label, bounds, !background, devtools, None)?; + attach_navigation_delegate(&app, &id, &webview); SURFACES.with(|s| s.borrow_mut().insert(id, webview)); Ok(()) })? @@ -670,6 +770,12 @@ fn new_window_handler( Ok(u) if policy::navigation_allowed(&u) => u, _ => return deny(&app, &opener_tab_id, &url, &features, "blocked-scheme"), }; + if app + .try_state::<BrowserPolicy>() + .is_some_and(|policy| policy.blocked(&parsed)) + { + return deny(&app, &opener_tab_id, &url, &features, "blocked-host"); + } let has_gesture = registry .recent_gestures(&opener_tab_id) .iter() @@ -698,6 +804,7 @@ fn new_window_handler( let platform = objc2::rc::Retained::into_super( tauri_runtime_wry::wry::WebViewExtMacOS::webview(&webview), ); + attach_navigation_delegate(&app, &tab_id, &webview); SURFACES.with(|s| s.borrow_mut().insert(tab_id.clone(), webview)); let handle = ChildHandle { tab_id: tab_id.clone(), @@ -741,7 +848,7 @@ fn new_window_handler( devtools, )) { tracing::warn!("[browser] popup registry insert failed: {err}"); - SURFACES.with(|s| s.borrow_mut().remove(&tab_id)); + drop_surface(&tab_id); return deny(&app, &opener_tab_id, &url, &features, "registry"); } // The engine navigates this webview itself; arm the failed-load diff --git a/src-tauri/src/browser/surface_window.rs b/src-tauri/src/browser/surface_window.rs index db71a4f765..f1e0a0a8af 100644 --- a/src-tauri/src/browser/surface_window.rs +++ b/src-tauri/src/browser/surface_window.rs @@ -8,9 +8,10 @@ use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBui use super::downloads; use super::events; use super::hooks; -use super::policy; +use super::policy::{self, BrowserPolicy}; use super::profile; use super::registry::BrowserRegistry; +use super::types::NavigationBlockReason; pub fn create( app: &AppHandle, @@ -28,7 +29,26 @@ pub fn create( .min_inner_size(480.0, 320.0) .focused(!background) .devtools(devtools) - .on_navigation(policy::navigation_allowed) + // tauri only asks about top-level navigations here, so every refusal + // is worth a notice. + .on_navigation({ + let app = app.clone(); + let tab_id = tab_id.to_string(); + move |url| { + if !policy::navigation_allowed(url) { + hooks::navigation_blocked(&app, &tab_id, url.as_str(), NavigationBlockReason::Scheme); + return false; + } + if app + .try_state::<BrowserPolicy>() + .is_some_and(|policy| policy.blocked(url)) + { + hooks::navigation_blocked(&app, &tab_id, url.as_str(), NavigationBlockReason::HostRule); + return false; + } + true + } + }) .on_page_load({ let app = app.clone(); let tab_id = tab_id.to_string(); diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index 223ddff8bd..64f84f9412 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -103,6 +103,23 @@ pub struct BrowserCapabilities { pub proxy: crate::browser::profile::BrowserProxyStatus, /// Where a page's downloads land, for the settings section. pub downloads_dir: String, + /// The administrator's policy in force (rules shown read-only, and + /// whether the browser is enabled at all). + pub policy: crate::browser::policy::BrowserPolicyStatus, +} + +/// The last frame of a page, handed back by `browser_set_visible` when the +/// frontend hides a surface under an overlay: the placeholder paints it so +/// the page does not vanish while a dialog or menu is open over it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrozenFrame { + /// `image/jpeg`. + pub mime: String, + /// Base64 of the encoded image. + pub data: String, + pub width: u32, + pub height: u32, } /// Caller's surface preference for `browser_open_tab`. @@ -126,6 +143,25 @@ pub const OPEN_REQUEST_EVENT: &str = "browser://open-request"; /// so the app's own DOM never sees the keystroke). Only the fixed set below /// is forwarded; the payload carries no page data. pub const SHORTCUT_EVENT: &str = "browser://shortcut"; +/// A top-level navigation a tab attempted was refused by policy: the address +/// type is not allowed in a tab, or a site rule blocks the host. The status +/// layer tells the user; nothing else happens. +pub const NAVIGATION_BLOCKED_EVENT: &str = "browser://navigation-blocked"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NavigationBlockReason { + HostRule, + Scheme, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserNavigationBlockedPayload { + pub tab_id: String, + pub url: String, + pub reason: NavigationBlockReason, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -215,5 +251,21 @@ mod tests { assert_eq!(bounds.y, 2.5); let choice: SurfaceChoice = serde_json::from_str(r#""window""#).unwrap(); assert_eq!(choice, SurfaceChoice::Window); + + let blocked = serde_json::to_value(BrowserNavigationBlockedPayload { + tab_id: "t1".into(), + url: "https://blocked.example/".into(), + reason: NavigationBlockReason::HostRule, + }) + .unwrap(); + assert_eq!(blocked["reason"], "host-rule"); + let frame = serde_json::to_value(FrozenFrame { + mime: "image/jpeg".into(), + data: "AAAA".into(), + width: 10, + height: 4, + }) + .unwrap(); + assert_eq!(frame["mime"], "image/jpeg"); } } diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 73658cd103..9dd2da5df7 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -3,15 +3,20 @@ //! dev-only smoke puppet, so every code path the frontend uses is the one the //! P0/P1 checks exercised. +use std::time::Duration; + +use base64::Engine as _; use tauri::{AppHandle, Manager, State, WebviewWindow}; use tauri::Url; use crate::app_error::AppCommandError; use crate::browser::downloads::{BrowserDownload, BrowserDownloads}; +use crate::browser::policy::{BrowserPolicy, HostRule}; use crate::browser::registry::{BrowserRegistry, BrowserTab}; use crate::browser::surface::BrowserSurface; use crate::browser::types::{ - Bounds, BrowserCapabilities, BrowserTabState, ChannelKind, SurfaceChoice, SurfaceKind, + Bounds, BrowserCapabilities, BrowserErrorInfo, BrowserErrorKind, BrowserTabState, ChannelKind, + FrozenFrame, SurfaceChoice, SurfaceKind, }; use crate::browser::{events, hooks, policy, tab_label}; @@ -40,9 +45,14 @@ fn platform_name() -> &'static str { /// What this build can do on this machine. `channel` stays `degraded` until /// the isolated-world channel installer lands; the frontend keys off -/// `available` and `surface`. -pub fn capabilities() -> BrowserCapabilities { +/// `available` and `surface`. An administrator's policy can turn the whole +/// feature off, in which case every link goes to the system browser. +pub fn capabilities(policy: &BrowserPolicy) -> BrowserCapabilities { let mut reasons = Vec::new(); + let enabled = policy.enabled(); + if !enabled { + reasons.push("disabled by the administrator's policy".to_string()); + } let surface = if CHILD_SURFACE_COMPILED { SurfaceKind::Child } else { @@ -55,17 +65,34 @@ pub fn capabilities() -> BrowserCapabilities { }; reasons.push("page channel not installed yet".to_string()); BrowserCapabilities { - available: true, - surface: Some(surface), + available: enabled, + surface: enabled.then_some(surface), platform: platform_name().to_string(), channel: ChannelKind::Degraded, reasons, isolated_storage: crate::browser::profile::isolated_storage(), proxy: crate::browser::profile::proxy_status(), downloads_dir: crate::browser::downloads::downloads_dir_display(), + policy: policy.status(), + } +} + +/// The error a tab shows for an address a site rule blocks. No message: the +/// status layer has its own wording, in the user's language. +fn blocked_error(url: &Url) -> BrowserErrorInfo { + BrowserErrorInfo { + kind: BrowserErrorKind::Blocked, + message: String::new(), + url: Some(url.to_string()), } } +/// Whether the policy in force refuses `url` outright. +fn blocked_by_policy(app: &AppHandle, url: &Url) -> bool { + app.try_state::<BrowserPolicy>() + .is_some_and(|policy| policy.blocked(url)) +} + fn pick_surface(choice: SurfaceChoice) -> SurfaceKind { if CHILD_SURFACE_COMPILED && choice != SurfaceChoice::Window { SurfaceKind::Child @@ -139,6 +166,14 @@ pub fn open_tab_core( params.tab_id ))); } + if app + .try_state::<BrowserPolicy>() + .is_some_and(|policy| !policy.enabled()) + { + return Err(AppCommandError::invalid_input( + "the built-in browser is disabled by the administrator's policy", + )); + } let url = parse_web_url(¶ms.url)?; let label = tab_label(¶ms.tab_id); crate::browser::profile::prepare(app) @@ -229,6 +264,19 @@ pub fn open_tab_core( if params.background && surface.is_embedded() { let _ = surface.hide(); } + // A blocked address gets its tab — the caller (an agent tool, a deep + // link) asked for one and the block page is where the user learns why — + // but nothing is loaded into it. + if blocked_by_policy(app, &url) { + let state = registry + .update_state(¶ms.tab_id, |s| { + s.loading = false; + s.error = Some(blocked_error(&url)); + }) + .unwrap_or(state); + events::emit_state(app, &state); + return Ok(state); + } if let Err(err) = surface.navigate(url) { registry.remove(¶ms.tab_id); let _ = surface.close(); @@ -348,14 +396,73 @@ pub fn set_bounds_core( Ok(()) } -pub fn set_visible_core( +/// JPEG quality of the freeze frame: legible text under a dimmed overlay, +/// small enough to ride the IPC without being noticed. +const FREEZE_JPEG_QUALITY: f64 = 0.8; +/// How long a hide may wait for its freeze frame. Longer than this and the +/// overlay would sit under the page for a visible moment; the hide then goes +/// ahead without a frame. +const FREEZE_CAPTURE_TIMEOUT: Duration = Duration::from_millis(250); + +/// The frame the surface shows now, for the placeholder to paint while the +/// surface is hidden. `None` whenever it cannot be had in time — a blank +/// placeholder is the state before this existed, never an error. +async fn capture_freeze_frame(surface: &BrowserSurface) -> Option<FrozenFrame> { + let (tx, rx) = tokio::sync::oneshot::channel::<Result<(Vec<u8>, u32, u32), String>>(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + if let Err(err) = surface.snapshot_jpeg(FREEZE_JPEG_QUALITY, move |result| { + if let Some(tx) = tx.lock().unwrap_or_else(|p| p.into_inner()).take() { + let _ = tx.send(result); + } + }) { + tracing::debug!("[browser] no freeze frame: {err}"); + return None; + } + match tokio::time::timeout(FREEZE_CAPTURE_TIMEOUT, rx).await { + Ok(Ok(Ok((bytes, width, height)))) => Some(FrozenFrame { + mime: "image/jpeg".to_string(), + data: base64::engine::general_purpose::STANDARD.encode(bytes), + width, + height, + }), + Ok(Ok(Err(err))) => { + tracing::debug!("[browser] freeze frame failed: {err}"); + None + } + Ok(Err(_)) | Err(_) => { + tracing::debug!("[browser] freeze frame did not arrive in time"); + None + } + } +} + +/// Show or hide a surface. Hiding with `freeze` first captures the frame the +/// surface shows and hands it back, so the placeholder can keep showing the +/// page while an overlay is open over it; the capture is asynchronous, and a +/// request that a newer one overtook meanwhile is dropped rather than +/// applied late. +pub async fn set_visible_core( owner: &WebviewWindow, registry: &BrowserRegistry, tab_id: &str, visible: bool, handoff_focus: bool, -) -> Result<(), AppCommandError> { + freeze: bool, +) -> Result<Option<FrozenFrame>, AppCommandError> { let surface = surface_of(registry, tab_id)?; + let seq = registry + .update(tab_id, |tab| { + tab.visible_seq += 1; + tab.visible_seq + }) + .unwrap_or(0); + let mut frame = None; + if !visible && freeze && surface.is_embedded() { + frame = capture_freeze_frame(&surface).await; + if registry.update(tab_id, |tab| tab.visible_seq) != Some(seq) { + return Ok(None); + } + } let bounds = registry .update(tab_id, |tab| { tab.visible = visible; @@ -381,7 +488,7 @@ pub fn set_visible_core( .hide() .map_err(|e| window_err("Failed to hide browser surface", e))?; } - Ok(()) + Ok(frame) } pub fn navigate_core( @@ -392,6 +499,19 @@ pub fn navigate_core( ) -> Result<BrowserTabState, AppCommandError> { let url = parse_web_url(raw_url)?; let surface = surface_of(registry, tab_id)?; + // Refused by a site rule: the block page takes the place of the page, + // as in a browser, and nothing is loaded. + if blocked_by_policy(app, &url) { + let state = registry + .update_state(tab_id, |state| { + state.requested_url = url.to_string(); + state.loading = false; + state.error = Some(blocked_error(&url)); + }) + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; + events::emit_state(app, &state); + return Ok(state); + } let state = registry .update_state(tab_id, |state| { state.requested_url = url.to_string(); @@ -510,8 +630,21 @@ pub fn state_core(registry: &BrowserRegistry, tab_id: &str) -> Result<BrowserTab } #[tauri::command] -pub async fn browser_capabilities() -> Result<BrowserCapabilities, AppCommandError> { - Ok(capabilities()) +pub async fn browser_capabilities( + policy: State<'_, BrowserPolicy>, +) -> Result<BrowserCapabilities, AppCommandError> { + Ok(capabilities(&policy)) +} + +/// The user's site rules, pushed by the frontend (which owns the preference) +/// at startup and on every change. Invalid patterns are dropped. +#[tauri::command] +pub async fn browser_set_host_rules( + policy: State<'_, BrowserPolicy>, + rules: Vec<HostRule>, +) -> Result<(), AppCommandError> { + policy.set_user_rules(rules); + Ok(()) } #[tauri::command] @@ -579,14 +712,17 @@ pub async fn browser_set_visible( tab_id: String, visible: bool, handoff_focus: Option<bool>, -) -> Result<(), AppCommandError> { + freeze: Option<bool>, +) -> Result<Option<FrozenFrame>, AppCommandError> { set_visible_core( &window, ®istry, &tab_id, visible, handoff_focus.unwrap_or(false), + freeze.unwrap_or(false), ) + .await } #[tauri::command] @@ -709,9 +845,26 @@ mod tests { #[test] fn capabilities_report_a_surface() { - let caps = capabilities(); + let caps = capabilities(&BrowserPolicy::default()); assert!(caps.available); assert!(caps.surface.is_some()); assert!(!caps.platform.is_empty()); + assert!(caps.policy.enabled); + } + + /// An administrator can turn the feature off: no surface is offered and + /// the reason is spelled out for the settings section. + #[test] + fn capabilities_follow_a_disabling_policy() { + let policy = BrowserPolicy::with_managed(crate::browser::policy::ManagedPolicy { + browser_enabled: false, + host_rules: Vec::new(), + source: None, + }); + let caps = capabilities(&policy); + assert!(!caps.available); + assert!(caps.surface.is_none()); + assert!(!caps.policy.enabled); + assert!(caps.reasons.iter().any(|r| r.contains("policy"))); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a3eae7a54..ae741b4406 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -395,6 +395,7 @@ mod tauri_app { .manage(ConnectionManager::new()) .manage(crate::browser::BrowserRegistry::default()) .manage(crate::browser::BrowserDownloads::default()) + .manage(crate::browser::policy::BrowserPolicy::load()) .manage(TerminalManager::new()) .manage(ChatChannelManager::new()) .manage(windows::SettingsWindowState::new()) @@ -1210,6 +1211,7 @@ mod tauri_app { browser_commands::browser_find, browser_commands::browser_list_downloads, browser_commands::browser_clear_downloads, + browser_commands::browser_set_host_rules, conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 13d5be7e3c..4e2dcb92bb 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -220,7 +220,8 @@ export function useOpenLinkOrFile() { source: "transcript", modifier: consumeLinkGestureModifier(), }) - if (action.kind === "reject") { + // A host blocked by a site rule is reported by the hook itself. + if (action.kind === "reject" && action.reason !== "blocked-host") { toast.error(t("errorFailedLink"), { description: t("errorUnsupportedLinkProtocol"), }) diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index d193ea72ee..8e9c803ca5 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -22,8 +22,10 @@ const mocks = vi.hoisted(() => { isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, }) ), + browserSetHostRules: vi.fn(() => Promise.resolve()), subscribe: vi.fn((event: string, handler: Handler) => { handlers.set(event, handler) return Promise.resolve(() => { @@ -58,6 +60,7 @@ vi.mock("@/lib/browser/browser-api", () => ({ browserClose: mocks.browserClose, browserListTabs: mocks.browserListTabs, browserListDownloads: mocks.browserListDownloads, + browserSetHostRules: mocks.browserSetHostRules, })) vi.mock("@/lib/transport", () => ({ getTransport: () => ({ subscribe: mocks.subscribe }), @@ -71,11 +74,16 @@ vi.mock("@/contexts/workspace-context", () => ({ }), })) +import { + resetBrowserPrefsForTests, + setBrowserHostRules, +} from "@/lib/browser/browser-prefs" import { getBrowserTabState, resetBrowserTabStoreForTests, setBrowserTabState, useBrowserFindRequest, + useBrowserTabNotice, } from "@/lib/browser/browser-tab-store" import { getBrowserDownloads, @@ -108,12 +116,15 @@ describe("BrowserEventsBridge", () => { mocks.openBrowserTab.mockClear() mocks.browserClose.mockClear() mocks.browserListDownloads.mockClear() + mocks.browserSetHostRules.mockClear() resetBrowserTabStoreForTests() resetBrowserDownloadsForTests() + resetBrowserPrefsForTests() }) afterEach(() => { resetBrowserTabStoreForTests() resetBrowserDownloadsForTests() + resetBrowserPrefsForTests() }) it("subscribes to the streams once the capabilities say a browser exists, after sweeping orphans", async () => { @@ -125,6 +136,7 @@ describe("BrowserEventsBridge", () => { expect([...mocks.handlers.keys()].sort()).toEqual([ "browser://closed", "browser://download", + "browser://navigation-blocked", "browser://open-request", "browser://popup", "browser://shortcut", @@ -260,6 +272,7 @@ describe("BrowserEventsBridge", () => { expect(mocks.unsubscribed.sort()).toEqual([ "browser://closed", "browser://download", + "browser://navigation-blocked", "browser://open-request", "browser://popup", "browser://shortcut", @@ -277,9 +290,46 @@ describe("BrowserEventsBridge", () => { isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, }) render(<BrowserEventsBridge />) await flush() expect(mocks.subscribe).not.toHaveBeenCalled() }) + + it("pushes the user's site rules to the backend at start and whenever they change", async () => { + const { unmount } = render(<BrowserEventsBridge />) + await flush() + expect(mocks.browserSetHostRules).toHaveBeenCalledWith([]) + await act(async () => { + setBrowserHostRules([{ pattern: "blocked.example", action: "block" }]) + }) + expect(mocks.browserSetHostRules).toHaveBeenLastCalledWith([ + { pattern: "blocked.example", action: "block" }, + ]) + unmount() + // After unmount the preference subscription is gone with the rest. + mocks.browserSetHostRules.mockClear() + await act(async () => { + setBrowserHostRules([]) + }) + expect(mocks.browserSetHostRules).not.toHaveBeenCalled() + }) + + it("turns a refused navigation into a notice on its tab", async () => { + render(<BrowserEventsBridge />) + await flush() + mocks.handlers.get("browser://navigation-blocked")!({ + tabId: "abc", + url: "https://blocked.example/", + reason: "host-rule", + }) + const view = renderHook(() => useBrowserTabNotice("browser:abc")) + expect(view.result.current).toEqual({ + kind: "navigation-blocked", + url: "https://blocked.example/", + reason: "host-rule", + }) + view.unmount() + }) }) diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 966f1b39e1..b8565de289 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -8,7 +8,12 @@ import { browserClose, browserListDownloads, browserListTabs, + browserSetHostRules, } from "@/lib/browser/browser-api" +import { + getBrowserPrefs, + subscribeBrowserPrefs, +} from "@/lib/browser/browser-prefs" import { hydrateBrowserDownloads, setBrowserDownload, @@ -23,12 +28,14 @@ import { import { BROWSER_CLOSED_EVENT, BROWSER_DOWNLOAD_EVENT, + BROWSER_NAVIGATION_BLOCKED_EVENT, BROWSER_OPEN_REQUEST_EVENT, BROWSER_POPUP_EVENT, BROWSER_SHORTCUT_EVENT, BROWSER_STATE_EVENT, type BrowserClosedPayload, type BrowserDownload, + type BrowserNavigationBlockedPayload, type BrowserOpenRequestPayload, type BrowserPopupPayload, type BrowserShortcutPayload, @@ -50,6 +57,13 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" * dev puppet) asks this window's workspace to open a URL * - `browser://download` → the download bar of the tab that started it * - `browser://shortcut` → a browser shortcut the page had focus for (⌘F) + * - `browser://navigation-blocked` → a notice on the tab whose navigation + * policy refused + * + * It also carries the user's site rules the other way: the backend enforces + * `block` on every navigation a tab attempts, and learns the table from here + * at startup and whenever the preference changes (the settings window writes + * it; the storage event brings it over). * * Only subscribes where a built-in browser exists; in web mode there is * nothing to hear. @@ -129,6 +143,16 @@ export function BrowserEventsBridge() { setBrowserDownload(download) } ), + transport.subscribe<BrowserNavigationBlockedPayload>( + BROWSER_NAVIGATION_BLOCKED_EVENT, + (blocked) => { + setBrowserTabNotice(browserWorkspaceTabId(blocked.tabId), { + kind: "navigation-blocked", + url: blocked.url, + reason: blocked.reason, + }) + } + ), transport.subscribe<BrowserOpenRequestPayload>( BROWSER_OPEN_REQUEST_EVENT, (request) => { @@ -150,6 +174,13 @@ export function BrowserEventsBridge() { return } unsubscribers.push(...subs) + const pushHostRules = () => { + void browserSetHostRules(getBrowserPrefs().hostRules).catch(() => { + /* the backend keeps its last table */ + }) + } + pushHostRules() + unsubscribers.push(subscribeBrowserPrefs(pushHostRules)) })() return () => { diff --git a/src/components/browser/browser-status-layer.test.tsx b/src/components/browser/browser-status-layer.test.tsx index 920386633e..fb8e0e6f67 100644 --- a/src/components/browser/browser-status-layer.test.tsx +++ b/src/components/browser/browser-status-layer.test.tsx @@ -6,12 +6,17 @@ const mocks = vi.hoisted(() => ({ actions: null as null | { openBrowserTab: ReturnType<typeof vi.fn> }, openUrl: vi.fn(), revealItemInDir: vi.fn(), + openWithOsHandler: vi.fn(), })) vi.mock("@/contexts/workspace-context", () => ({ useOptionalWorkspaceActions: () => mocks.actions, })) vi.mock("@/lib/browser/browser-api", () => ({ browserReload: vi.fn() })) +vi.mock("@/lib/link-open", () => ({ + openWithOsHandler: mocks.openWithOsHandler, + openInSystemBrowser: vi.fn(), +})) vi.mock("@/lib/platform", () => ({ openUrl: mocks.openUrl, revealItemInDir: mocks.revealItemInDir, @@ -62,6 +67,7 @@ beforeEach(() => { mocks.actions = null mocks.openUrl.mockClear() mocks.revealItemInDir.mockClear() + mocks.openWithOsHandler.mockClear() }) describe("BrowserNoticeBar", () => { @@ -128,6 +134,67 @@ function renderError(error: { ) } +describe("BrowserNoticeBar — refused navigations", () => { + it("names the host a site rule blocked, with nothing to click but dismiss", () => { + mocks.actions = { openBrowserTab: vi.fn() } + setBrowserTabNotice("browser:abc", { + kind: "navigation-blocked", + url: "https://blocked.example/path", + reason: "host-rule", + }) + renderBar() + expect( + screen.getByText( + /Navigation blocked: blocked\.example · blocked by a site rule/ + ) + ).toBeInTheDocument() + expect(screen.queryByText("Open anyway")).not.toBeInTheDocument() + expect(screen.queryByText("Open with system app")).not.toBeInTheDocument() + fireEvent.click(screen.getByLabelText("Dismiss")) + expect(screen.queryByText(/Navigation blocked/)).not.toBeInTheDocument() + }) + + // A `mailto:` the page pointed at is not a page, but the OS can take it — + // the same hand-off the transcript makes for that scheme. + it("offers the OS handler for a mailto: the tab refused, and nothing for other schemes", () => { + setBrowserTabNotice("browser:abc", { + kind: "navigation-blocked", + url: "mailto:someone@example.com", + reason: "scheme", + }) + const { unmount } = renderBar() + expect( + screen.getByText(/address type not allowed here/) + ).toBeInTheDocument() + fireEvent.click(screen.getByText("Open with system app")) + expect(mocks.openWithOsHandler).toHaveBeenCalledWith( + "mailto:someone@example.com" + ) + expect(screen.queryByText(/Navigation blocked/)).not.toBeInTheDocument() + unmount() + + setBrowserTabNotice("browser:abc", { + kind: "navigation-blocked", + url: "vscode://file/x", + reason: "scheme", + }) + renderBar() + expect(screen.queryByText("Open with system app")).not.toBeInTheDocument() + }) + + it("does not offer to open a pop-up a site rule refused", () => { + mocks.actions = { openBrowserTab: vi.fn() } + setBrowserTabNotice("browser:abc", { + kind: "popup-denied", + url: "https://blocked.example/", + reason: "blocked-host", + }) + renderBar() + expect(screen.getByText(/blocked by a site rule/)).toBeInTheDocument() + expect(screen.queryByText("Open anyway")).not.toBeInTheDocument() + }) +}) + describe("BrowserErrorPage", () => { it("explains a navigation that never produced a page", () => { renderError({ @@ -150,6 +217,37 @@ describe("BrowserErrorPage", () => { expect( screen.queryByText(/a proxy may be required/) ).not.toBeInTheDocument() + expect(screen.getByText("Open in system browser")).toBeInTheDocument() + }) + + // The engine's description (system language) and our hint (user language) + // are both worth showing: one says what happened, the other what to do. + it("shows the platform's description and the hint together for a failed load", () => { + renderError({ + kind: "failed", + message: "Could not connect to the server.", + url: "https://down.example/", + }) + expect( + screen.getByText("Could not connect to the server.") + ).toBeInTheDocument() + expect(screen.getByText(/a proxy may be required/)).toBeInTheDocument() + }) + + it("explains a site-rule block and does not offer the system browser", () => { + renderError({ + kind: "blocked", + message: "", + url: "https://blocked.example/", + }) + expect( + screen.getByText("This address is blocked in the built-in browser") + ).toBeInTheDocument() + expect( + screen.getByText(/A site rule blocks this address/) + ).toBeInTheDocument() + expect(screen.getByText("Retry")).toBeInTheDocument() + expect(screen.queryByText("Open in system browser")).not.toBeInTheDocument() }) }) diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index c399c0c742..f454ddbe80 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -22,12 +22,41 @@ import { import { setBrowserTabNotice, useBrowserTabNotice, + type BrowserTabNotice, } from "@/lib/browser/browser-tab-store" import { displayHostPort } from "@/lib/browser/browser-url" import type { BrowserErrorInfo, BrowserTabState } from "@/lib/browser/types" import { browserTabBackendId } from "@/lib/file-tab-id" +import { getAllowedExternalProtocol } from "@/lib/link-classify" +import { openWithOsHandler } from "@/lib/link-open" import { openUrl, revealItemInDir } from "@/lib/platform" +function noticeText( + t: ReturnType<typeof useTranslations<"Browser.status">>, + notice: BrowserTabNotice +): string { + const host = displayHostPort(notice.url) ?? notice.url + if (notice.kind === "navigation-blocked") { + if (notice.reason === "scheme") { + // Not a web address, so its "host" would mislead (`vscode://file/…` + // has a host of `file`): name the whole thing, as typed by the page. + return `${t("navigationBlocked", { host: notice.url })} · ${t("navigationBlockedScheme")}` + } + return `${t("navigationBlocked", { host })} · ${t("navigationBlockedRule")}` + } + const why = + notice.reason === "no-gesture" + ? t("popupDeniedNoGesture") + : notice.reason === "blocked-scheme" + ? t("popupDeniedBlockedScheme") + : notice.reason === "blocked-host" + ? t("popupDeniedBlockedHost") + : null + return why + ? `${t("popupDenied", { host })} · ${why}` + : t("popupDenied", { host }) +} + /** Bars that sit OUTSIDE the native surface's rect (a native view paints over * any DOM placed on top of it): blocked popups, remote-egress banner. */ export function BrowserNoticeBar({ @@ -54,19 +83,33 @@ export function BrowserNoticeBar({ <div className="flex h-8 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 text-xs text-foreground"> <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-amber-600" /> <span className="min-w-0 flex-1 truncate"> - {t("popupDenied", { - host: displayHostPort(notice.url) ?? notice.url, - })} - {notice.reason === "no-gesture" - ? ` · ${t("popupDeniedNoGesture")}` - : notice.reason === "blocked-scheme" - ? ` · ${t("popupDeniedBlockedScheme")}` - : ""} + {noticeText(t, notice)} </span> + {/* A `mailto:` / `tel:` link the page pointed at: not a page, so + not for a tab, but the OS has a handler for it — the same + hand-off the transcript makes for those two schemes. Any + other refused scheme stays refused. */} + {notice.kind === "navigation-blocked" && + notice.reason === "scheme" && + getAllowedExternalProtocol(notice.url) ? ( + <button + type="button" + className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/8" + onClick={() => { + void openWithOsHandler(notice.url) + setBrowserTabNotice(tab.id, null) + }} + > + {t("navigationBlockedOpenSystem")} + </button> + ) : null} {/* Opens the blocked address as a plain tab: the page's own `window.open` is gone, so there is no opener to preserve — the - same trade a browser's "show blocked pop-up" makes. */} - {actions ? ( + same trade a browser's "show blocked pop-up" makes. A pop-up a + site rule refused stays refused. */} + {notice.kind === "popup-denied" && + notice.reason !== "blocked-host" && + actions ? ( <button type="button" className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/8" @@ -123,10 +166,18 @@ export function BrowserErrorPage({ }) { const t = useTranslations("Browser.status") const backendId = browserTabBackendId(tab.id) - // Platform errors carry their own (untranslated) text; the one we raise - // ourselves for a navigation that never produced a page does not. - const detail = - error.message || (error.kind === "failed" ? t("errorFailedHint") : "") + // Platform errors carry their own text (in the system language); the + // hint is ours, in the user's, and says what to do about it. + const detail = error.message + const hint = + error.kind === "failed" + ? t("errorFailedHint") + : error.kind === "blocked" + ? t("errorBlockedHint") + : "" + // A site rule means "not this host": offering the system browser would + // undo the rule with one click. + const blocked = error.kind === "blocked" return ( <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> <ShieldAlert className="h-8 w-8 text-muted-foreground/60" /> @@ -139,6 +190,9 @@ export function BrowserErrorPage({ {detail ? ( <p className="max-w-md text-xs text-muted-foreground/80">{detail}</p> ) : null} + {hint ? ( + <p className="max-w-md text-xs text-muted-foreground/80">{hint}</p> + ) : null} <div className="mt-1 flex items-center gap-2"> <button type="button" @@ -148,14 +202,16 @@ export function BrowserErrorPage({ <RotateCw className="h-3.5 w-3.5" /> {t("retry")} </button> - <button - type="button" - className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" - onClick={() => void openUrl(error.url || url)} - > - <ExternalLink className="h-3.5 w-3.5" /> - {t("openInSystem")} - </button> + {blocked ? null : ( + <button + type="button" + className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" + onClick={() => void openUrl(error.url || url)} + > + <ExternalLink className="h-3.5 w-3.5" /> + {t("openInSystem")} + </button> + )} </div> </div> ) diff --git a/src/components/browser/browser-surface-host.test.tsx b/src/components/browser/browser-surface-host.test.tsx index 52c84f402e..27805aaa28 100644 --- a/src/components/browser/browser-surface-host.test.tsx +++ b/src/components/browser/browser-surface-host.test.tsx @@ -2,12 +2,19 @@ import { act, render } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" -import type { BrowserTabState } from "@/lib/browser/types" +import type { BrowserTabState, FrozenFrame } from "@/lib/browser/types" const api = vi.hoisted(() => ({ browserOpenTab: vi.fn(), browserSetBounds: vi.fn(() => Promise.resolve()), - browserSetVisible: vi.fn(() => Promise.resolve()), + browserSetVisible: vi.fn< + ( + id: string, + visible: boolean, + handoff: boolean, + freeze?: boolean + ) => Promise<FrozenFrame | null> + >(() => Promise.resolve(null)), })) vi.mock("@/lib/browser/browser-api", () => api) vi.mock("@/contexts/workspace-context", () => ({ @@ -115,8 +122,14 @@ describe("BrowserSurfaceHost", () => { release = acquireNativeSurfaceOcclusion("dialog") await new Promise((resolve) => setTimeout(resolve, 0)) }) - // Hidden with focus handoff. - expect(api.browserSetVisible).toHaveBeenLastCalledWith("host1", false, true) + // Hidden with focus handoff, and a freeze frame requested: the + // placeholder stays on screen under the overlay. + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host1", + false, + true, + true + ) await act(async () => { release() @@ -183,10 +196,80 @@ describe("BrowserSurfaceHost", () => { expect(api.browserSetVisible).toHaveBeenLastCalledWith("host2", true, false) }) + // Under an overlay the placeholder stays on screen, so the hide asks for + // the page's last frame and paints it until the surface shows again. + it("paints the freeze frame while hidden under an overlay and drops it once shown", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host4"))) + let resolveShow: () => void = () => {} + api.browserSetVisible.mockImplementation( + (_id: string, visible: boolean, _handoff: boolean, freeze?: boolean) => { + if (visible) { + return new Promise<null>((resolve) => { + resolveShow = () => resolve(null) + }) + } + return Promise.resolve( + freeze + ? { mime: "image/jpeg", data: "QUJD", width: 1600, height: 1200 } + : null + ) + } + ) + const { container } = render(<BrowserSurfaceHost tab={tab("host4")} />) + await flush() + + let release: () => void = () => {} + await act(async () => { + release = acquireNativeSurfaceOcclusion("dialog") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host4", + false, + true, + true + ) + const frame = container.querySelector("img[data-browser-frozen-frame]") + expect(frame).not.toBeNull() + expect(frame?.getAttribute("src")).toBe("data:image/jpeg;base64,QUJD") + + await act(async () => { + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(api.browserSetVisible).toHaveBeenLastCalledWith("host4", true, false) + // Still painted until the native view is back: no blank frame between. + expect( + container.querySelector("img[data-browser-frozen-frame]") + ).not.toBeNull() + await act(async () => { + resolveShow() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(container.querySelector("img[data-browser-frozen-frame]")).toBeNull() + }) + + it("does not ask for a frame when the error page hides the surface", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host5"))) + render(<BrowserSurfaceHost tab={tab("host5")} hidden />) + await flush() + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host5", + false, + true, + false + ) + }) + it("stays hidden while the view is force-hidden (error page)", async () => { api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host3"))) render(<BrowserSurfaceHost tab={tab("host3")} hidden />) await flush() - expect(api.browserSetVisible).toHaveBeenLastCalledWith("host3", false, true) + expect(api.browserSetVisible).toHaveBeenLastCalledWith( + "host3", + false, + true, + false + ) }) }) diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index df762f14b1..e1a77351af 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -86,6 +86,13 @@ export function BrowserSurfaceHost({ const lastBoundsRef = useRef<Bounds | null>(null) const lastVisibleRef = useRef<boolean | null>(null) const [createError, setCreateError] = useState<string | null>(null) + // The page's last frame, painted here while the surface is hidden under + // an overlay (a data URL). Cleared once the surface is showing again — not + // before, or the pane would flash blank between the two. + const [frozen, setFrozen] = useState<string | null>(null) + // Which hide a frame belongs to: one that lands after a later show or hide + // is dropped rather than painted over the wrong state. + const hideSeqRef = useRef(0) const occluded = useNativeSurfaceOccluded() const fallbackOverlay = useFallbackOverlayOpen() const view = useWorkspaceView() @@ -95,6 +102,11 @@ export function BrowserSurfaceHost({ const hostHidden = useOverlayHostHidden() const shouldShow = !hidden && !occluded && !fallbackOverlay && routeVisible && !hostHidden + // Hidden ONLY because an overlay is open over it: the placeholder stays on + // screen, so it is worth a freeze frame. The other reasons (error page, + // full-page route, hidden column) take the placeholder off screen too. + const overlayHide = + !hidden && routeVisible && !hostHidden && (occluded || fallbackOverlay) // One pass: measure, push bounds if they moved, push visibility if it // flipped. Called from every signal that could change either. @@ -122,9 +134,28 @@ export function BrowserSurfaceHost({ } if (lastVisibleRef.current !== visible) { lastVisibleRef.current = visible - void browserSetVisible(backendId, visible, !visible).catch(() => {}) + hideSeqRef.current += 1 + if (visible) { + void browserSetVisible(backendId, true, false) + .catch(() => {}) + .finally(() => setFrozen(null)) + } else { + const seq = hideSeqRef.current + const freeze = + overlayHide && + bounds.width > 0 && + bounds.height > 0 && + elementVisible(el) + void browserSetVisible(backendId, false, true, freeze) + .then((frame) => { + if (frame && hideSeqRef.current === seq) { + setFrozen(`data:${frame.mime};base64,${frame.data}`) + } + }) + .catch(() => {}) + } } - }, [backendId, shouldShow, tab.id]) + }, [backendId, overlayHide, shouldShow, tab.id]) // Whether this tab currently has a live surface. Also the re-creation // signal: if the state goes away while this host is mounted — the tab was @@ -238,6 +269,7 @@ export function BrowserSurfaceHost({ markBrowserTabShown(tab.id) return () => { lastVisibleRef.current = null + hideSeqRef.current += 1 markBrowserTabHidden(tab.id) void browserSetVisible(backendId, false, false).catch(() => {}) } @@ -253,6 +285,18 @@ export function BrowserSurfaceHost({ )} aria-hidden > + {frozen ? ( + // A data URL the backend just produced, shown for the life of an + // overlay: nothing for next/image to optimise, load or cache. + // eslint-disable-next-line @next/next/no-img-element + <img + src={frozen} + alt="" + draggable={false} + data-browser-frozen-frame="" + className="pointer-events-none absolute inset-0 h-full w-full select-none object-cover object-left-top" + /> + ) : null} {createError ? ( <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-destructive"> {createError} diff --git a/src/components/browser/browser-tabs-persistence.test.tsx b/src/components/browser/browser-tabs-persistence.test.tsx index c8fc8a66fa..b497d398f9 100644 --- a/src/components/browser/browser-tabs-persistence.test.tsx +++ b/src/components/browser/browser-tabs-persistence.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, }) ), restoreBrowserTabs: vi.fn(), @@ -156,6 +157,7 @@ describe("BrowserTabsPersistence", () => { isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, }) await Promise.resolve() }) @@ -193,6 +195,7 @@ describe("BrowserTabsPersistence", () => { isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, }) render(<BrowserTabsPersistence />) await act(async () => { diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index a79c1254d8..5be5f8ba07 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -49,6 +49,8 @@ function capabilitiesWith(proxy: { reasons: [], isolatedStorage: true, proxy, + downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, } } @@ -88,6 +90,64 @@ describe("BrowserSettingsSection", () => { ).toHaveTextContent("Automatic") }) + it("adds, validates and removes site rules, writing the preference at once", () => { + renderSection() + expandSection() + expect(screen.getByText("No rules yet.")).toBeInTheDocument() + + const pattern = screen.getByRole("textbox", { name: "Site pattern" }) + fireEvent.change(pattern, { target: { value: " Blocked.Example " } }) + fireEvent.click(screen.getByRole("button", { name: "Add" })) + // Stored normalized, with the picker's default action. + expect(getBrowserPrefs().hostRules).toEqual([ + { pattern: "blocked.example", action: "system" }, + ]) + expect(screen.getByText("blocked.example")).toBeInTheDocument() + expect(screen.queryByText("No rules yet.")).not.toBeInTheDocument() + expect(pattern).toHaveValue("") + + fireEvent.change(pattern, { target: { value: "blocked.example" } }) + fireEvent.click(screen.getByRole("button", { name: "Add" })) + expect( + screen.getByText("There is already a rule for this pattern") + ).toBeInTheDocument() + expect(getBrowserPrefs().hostRules).toHaveLength(1) + + fireEvent.change(pattern, { target: { value: "https://not a pattern" } }) + fireEvent.click(screen.getByRole("button", { name: "Add" })) + expect(screen.getByText("Not a valid pattern")).toBeInTheDocument() + expect(getBrowserPrefs().hostRules).toHaveLength(1) + + fireEvent.click(screen.getByRole("button", { name: "Remove rule" })) + expect(getBrowserPrefs().hostRules).toEqual([]) + expect(screen.getByText("No rules yet.")).toBeInTheDocument() + }) + + it("shows the administrator's rules read-only and says when the browser is turned off", async () => { + mocks.browserCapabilitiesNow.mockResolvedValue({ + ...capabilitiesWith({ url: null, applies: "live", reason: null }), + available: false, + policy: { + enabled: false, + managedRules: [{ pattern: "*.internal.example", action: "block" }], + managedSource: "/etc/codeg/policy.json", + }, + }) + renderSection() + expandSection() + expect(await screen.findByText("*.internal.example")).toBeInTheDocument() + expect( + screen.getByText(/turned off by your administrator/) + ).toBeInTheDocument() + expect( + screen.getAllByLabelText("Set by your administrator").length + ).toBeGreaterThan(0) + // Nothing to remove: it is not the user's row. + expect( + screen.queryByRole("button", { name: "Remove rule" }) + ).not.toBeInTheDocument() + }) + it("persists the background-unload switch, which is off by default", () => { renderSection() expandSection() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index e43d2ebccf..6133b8d524 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -21,8 +21,12 @@ import { Eraser, Globe, Link2, + ListFilter, + Lock, MoonStar, Network, + Plus, + Trash2, Wrench, } from "lucide-react" import { toast } from "sonner" @@ -40,6 +44,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { Select, SelectContent, @@ -56,6 +61,7 @@ import { import { LINK_SOURCES, setBrowserDevtools, + setBrowserHostRules, setBrowserSurfaceOverride, setBrowserSuspendBackgroundTabs, setDefaultLinkTarget, @@ -64,7 +70,18 @@ import { type LinkTarget, type SurfaceOverride, } from "@/lib/browser/browser-prefs" -import type { BrowserProxyStatus } from "@/lib/browser/types" +import { + HOST_RULE_ACTIONS, + normalizeHostRulePattern, + validateHostRulePattern, + type HostRule, + type HostRuleAction, +} from "@/lib/browser/host-rules" +import type { + BrowserPolicyStatus, + BrowserProxyStatus, + WireHostRule, +} from "@/lib/browser/types" import { isDesktop } from "@/lib/platform" // Literal message keys per id — next-intl only resolves literal keys, so the @@ -83,6 +100,12 @@ const TARGET_LABEL_KEYS = { system: "targetSystem", } as const satisfies Record<LinkTarget, string> +const ACTION_LABEL_KEYS = { + builtin: "targetBuiltin", + system: "targetSystem", + block: "ruleActionBlock", +} as const satisfies Record<HostRuleAction, string> + const SURFACES: readonly SurfaceOverride[] = ["auto", "child", "window"] const SURFACE_LABEL_KEYS = { auto: "surfaceAuto", @@ -111,6 +134,176 @@ export function proxyStatusLines( return lines } +/** + * The site-rule table: the administrator's rows first (read-only, with a + * lock), then the user's, then a line to add one. Every change is written at + * once, like the rest of the section. There is no ordering to manage: the + * most specific pattern wins, which the hint says. + */ +function HostRulesEditor({ + rules, + managed, +}: { + rules: readonly HostRule[] + managed: readonly WireHostRule[] +}) { + const t = useTranslations("BrowserSettings") + const [draft, setDraft] = useState("") + const [draftAction, setDraftAction] = useState<HostRuleAction>("system") + const [problem, setProblem] = useState<"invalid" | "duplicate" | null>(null) + + const add = () => { + if (validateHostRulePattern(draft)) { + setProblem("invalid") + return + } + const pattern = normalizeHostRulePattern(draft) + if (rules.some((rule) => rule.pattern === pattern)) { + setProblem("duplicate") + return + } + setBrowserHostRules([...rules, { pattern, action: draftAction }]) + setDraft("") + setProblem(null) + } + const setAction = (index: number, action: HostRuleAction) => { + setBrowserHostRules( + rules.map((rule, i) => (i === index ? { ...rule, action } : rule)) + ) + } + const remove = (index: number) => { + setBrowserHostRules(rules.filter((_, i) => i !== index)) + } + + return ( + <div className="space-y-1.5"> + {managed.map((rule) => ( + <div + key={`managed:${rule.pattern}`} + className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/40 px-3 py-2" + title={t("ruleManaged")} + > + <span className="flex min-w-0 items-center gap-2"> + <Lock + className="h-3.5 w-3.5 shrink-0 text-muted-foreground" + aria-label={t("ruleManaged")} + /> + <span className="truncate font-mono text-xs">{rule.pattern}</span> + </span> + <span className="shrink-0 text-xs text-muted-foreground"> + {t(ACTION_LABEL_KEYS[rule.action])} + </span> + </div> + ))} + {rules.map((rule, index) => ( + <div + key={rule.pattern} + className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background px-3 py-2" + > + <span className="min-w-0 truncate font-mono text-xs"> + {rule.pattern} + </span> + <div className="flex shrink-0 items-center gap-1"> + <Select + value={rule.action} + onValueChange={(value) => + setAction(index, value as HostRuleAction) + } + > + <SelectTrigger + size="sm" + className="w-40 bg-background text-xs" + aria-label={t("ruleActionFor", { pattern: rule.pattern })} + > + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {HOST_RULE_ACTIONS.map((action) => ( + <SelectItem key={action} value={action}> + {t(ACTION_LABEL_KEYS[action])} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + type="button" + variant="ghost" + size="icon" + className="h-7 w-7" + title={t("ruleRemove")} + aria-label={t("ruleRemove")} + onClick={() => remove(index)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + ))} + {rules.length === 0 && managed.length === 0 ? ( + <p className="text-xs text-muted-foreground">{t("rulesEmpty")}</p> + ) : null} + <form + className="flex items-start gap-2" + onSubmit={(event) => { + event.preventDefault() + add() + }} + > + <div className="min-w-0 flex-1"> + <Input + value={draft} + onChange={(event) => { + setDraft(event.target.value) + if (problem) setProblem(null) + }} + placeholder={t("rulePatternPlaceholder")} + aria-label={t("rulePatternLabel")} + aria-invalid={problem ? true : undefined} + className="h-8 bg-background font-mono text-xs" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + {problem ? ( + <p className="mt-1 text-xs text-destructive"> + {t(problem === "duplicate" ? "ruleDuplicate" : "ruleInvalid")} + </p> + ) : null} + </div> + <Select + value={draftAction} + onValueChange={(value) => setDraftAction(value as HostRuleAction)} + > + <SelectTrigger + size="sm" + className="w-40 bg-background text-xs" + aria-label={t("ruleActionLabel")} + > + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {HOST_RULE_ACTIONS.map((action) => ( + <SelectItem key={action} value={action}> + {t(ACTION_LABEL_KEYS[action])} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + type="submit" + variant="outline" + size="sm" + className="bg-background" + > + <Plus className="h-3.5 w-3.5" /> + {t("ruleAdd")} + </Button> + </form> + </div> + ) +} + export function BrowserSettingsSection() { const t = useTranslations("BrowserSettings") const prefs = useBrowserPrefs() @@ -121,6 +314,7 @@ export function BrowserSettingsSection() { const [clearing, setClearing] = useState(false) const [proxy, setProxy] = useState<BrowserProxyStatus | null>(null) const [downloadsDir, setDownloadsDir] = useState<string | null>(null) + const [policy, setPolicy] = useState<BrowserPolicyStatus | null>(null) // Fetched when the section opens (not once per app run): the answer follows // the proxy setting, which lives on another settings page. @@ -132,11 +326,13 @@ export function BrowserSettingsSection() { if (cancelled) return setProxy(caps.proxy) setDownloadsDir(caps.downloadsDir || null) + setPolicy(caps.policy ?? null) }) .catch(() => { if (cancelled) return setProxy(null) setDownloadsDir(null) + setPolicy(null) }) return () => { cancelled = true @@ -167,6 +363,11 @@ export function BrowserSettingsSection() { open={expanded} onOpenChange={setExpanded} > + {policy && !policy.enabled ? ( + <p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-foreground"> + {t("managedDisabled")} + </p> + ) : null} <SettingCard> {/* One setting with five values, so one row whose control is the list — not five rows repeating the same explanation. */} @@ -212,6 +413,19 @@ export function BrowserSettingsSection() { </SettingRow> </SettingCard> + <SettingCard> + <SettingRow + icon={ListFilter} + title={t("rulesTitle")} + description={t("rulesHint")} + > + <HostRulesEditor + rules={prefs.hostRules} + managed={policy?.managedRules ?? []} + /> + </SettingRow> + </SettingCard> + <SettingCard> <SettingRow icon={Wrench} diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx index d4a1202080..e6818c1aaf 100644 --- a/src/hooks/use-open-url-target.test.tsx +++ b/src/hooks/use-open-url-target.test.tsx @@ -47,6 +47,7 @@ const AVAILABLE = { isolatedStorage: true, proxy: { url: null, applies: "live" as const, reason: null }, downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, } describe("useOpenUrlTarget", () => { diff --git a/src/hooks/use-open-url-target.ts b/src/hooks/use-open-url-target.ts index 39d5223c57..7c91268f6c 100644 --- a/src/hooks/use-open-url-target.ts +++ b/src/hooks/use-open-url-target.ts @@ -15,6 +15,7 @@ import { type LinkSource, type LinkTarget, } from "@/lib/browser/browser-prefs" +import { displayHostPort } from "@/lib/browser/browser-url" import { openInSystemBrowser, openWithOsHandler } from "@/lib/link-open" import { resolveLinkAction, @@ -49,9 +50,10 @@ export function isPrimaryModifier(event: { * touches the app: the built-in browser tab (or the transcript's side panel * under a full-page route), the system browser, or the OS handler. * - * Returns the action taken so callers can react (a `reject` shows nothing - * here — the caller owns the error toast wording). Local file paths are not - * handled: they are `useOpenFileTarget`'s job and never reach this hook. + * Returns the action taken so callers can react. Of the rejections only the + * site-rule block is reported here (its wording is the browser's); the caller + * owns the toast for an unsupported scheme. Local file paths are not handled: + * they are `useOpenFileTarget`'s job and never reach this hook. */ export function useOpenUrlTarget() { const t = useTranslations("Browser.toast") @@ -80,6 +82,8 @@ export function useOpenUrlTarget() { forceTarget: options.forceTarget, surface, prefs, + hostRules: prefs.hostRules, + managedHostRules: capabilities?.policy.managedRules, }) switch (action.kind) { case "system": @@ -108,8 +112,18 @@ export function useOpenUrlTarget() { } break } - case "file": case "reject": + // The one rejection this hook owns the wording of: a site rule + // decided, and the user should learn which host it was. + if (action.reason === "blocked-host") { + toast.error( + t("blockedHost", { + host: displayHostPort(action.url) ?? action.url, + }) + ) + } + break + case "file": break } return action diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 2cdcadb910..cc81fd8259 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4764,7 +4764,21 @@ "suspendTitle": "إلغاء تحميل علامات التبويب في الخلفية", "suspendHint": "يحرّر ذاكرة علامات تبويب المتصفح التي تبقى في الخلفية لمدة 30 دقيقة. يُعاد تحميلها عند العودة إليها؛ ويُفقد موضع التمرير والسجل.", "downloadsTitle": "التنزيلات", - "downloadsHint": "تُحفظ الملفات التي تنزّلها الصفحة في {dir}. لا يُفتح أي ملف تلقائيًا، ولا يُستبدل ملف موجود أبدًا." + "downloadsHint": "تُحفظ الملفات التي تنزّلها الصفحة في {dir}. لا يُفتح أي ملف تلقائيًا، ولا يُستبدل ملف موجود أبدًا.", + "rulesTitle": "قواعد المواقع", + "rulesHint": "لكل موقع: دائمًا المتصفح المدمج، أو دائمًا متصفح النظام، أو الحظر. الأنماط هي اسم مضيف أو *.example.com أو *، مع :منفذ اختياري؛ وتفوز المطابقة الأكثر تحديدًا.", + "rulePatternPlaceholder": "example.com أو *.example.com", + "rulePatternLabel": "نمط الموقع", + "ruleActionLabel": "الإجراء", + "ruleActionFor": "الإجراء لـ {pattern}", + "ruleAdd": "إضافة", + "ruleRemove": "إزالة القاعدة", + "ruleActionBlock": "حظر", + "ruleManaged": "محدّد من قِبل المسؤول", + "ruleInvalid": "ليس نمطًا صالحًا", + "ruleDuplicate": "توجد قاعدة لهذا النمط بالفعل", + "rulesEmpty": "لا توجد قواعد بعد.", + "managedDisabled": "أوقفت سياسة المسؤول المتصفح المدمج؛ تُفتح الروابط في متصفح النظام." }, "LogsSettings": { "loading": "جارٍ التحميل…", @@ -5829,7 +5843,13 @@ "openInSystem": "فتح في متصفح النظام", "ownedWindow": "تُعرض هذه الصفحة في نافذة مستقلة.", "ownedWindowShow": "إظهار النافذة", - "remoteBanner": "فُتح عبر {host}" + "remoteBanner": "فُتح عبر {host}", + "navigationBlocked": "تم حظر الانتقال: {host}", + "navigationBlockedRule": "محظور بقاعدة موقع", + "navigationBlockedScheme": "لا يمكن فتح هذا النوع من العناوين هنا", + "navigationBlockedOpenSystem": "فتح بتطبيق النظام", + "errorBlockedHint": "تحظر قاعدة موقع هذا العنوان. تُدار القواعد في الإعدادات ← المتصفح المدمج.", + "popupDeniedBlockedHost": "محظور بقاعدة موقع" }, "tab": { "untitled": "علامة تبويب جديدة" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "تم الفتح في المتصفح المدمج", "firstOpenHint": "اضغط ⌘/Ctrl أثناء النقر على رابط لفتحه في متصفح النظام بدلًا من ذلك. يمكن تغيير الافتراضي من الإعدادات.", - "useSystemAlways": "استخدام متصفح النظام دائمًا" + "useSystemAlways": "استخدام متصفح النظام دائمًا", + "blockedHost": "محظور بقاعدة موقع: {host}" }, "link": { "openBuiltin": "فتح في المتصفح المدمج", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0218ae128c..588089ce83 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4764,7 +4764,21 @@ "suspendTitle": "Hintergrund-Tabs entladen", "suspendHint": "Gibt den Speicher von Browser-Tabs frei, die 30 Minuten im Hintergrund waren. Beim Zurückwechseln werden sie neu geladen; Scrollposition und Verlauf gehen verloren.", "downloadsTitle": "Downloads", - "downloadsHint": "Von einer Seite geladene Dateien werden in {dir} gespeichert. Nichts wird automatisch geöffnet, und eine vorhandene Datei wird nie ersetzt." + "downloadsHint": "Von einer Seite geladene Dateien werden in {dir} gespeichert. Nichts wird automatisch geöffnet, und eine vorhandene Datei wird nie ersetzt.", + "rulesTitle": "Site-Regeln", + "rulesHint": "Pro Site: immer der integrierte Browser, immer der Systembrowser oder blockieren. Muster sind ein Hostname, *.example.com oder *, optional mit :Port; der spezifischste Treffer gewinnt.", + "rulePatternPlaceholder": "example.com oder *.example.com", + "rulePatternLabel": "Site-Muster", + "ruleActionLabel": "Aktion", + "ruleActionFor": "Aktion für {pattern}", + "ruleAdd": "Hinzufügen", + "ruleRemove": "Regel entfernen", + "ruleActionBlock": "Blockieren", + "ruleManaged": "Von der Administration festgelegt", + "ruleInvalid": "Kein gültiges Muster", + "ruleDuplicate": "Für dieses Muster gibt es bereits eine Regel", + "rulesEmpty": "Noch keine Regeln.", + "managedDisabled": "Die Richtlinie der Administration hat den integrierten Browser abgeschaltet; Links öffnen sich im Systembrowser." }, "LogsSettings": { "loading": "Wird geladen…", @@ -5829,7 +5843,13 @@ "openInSystem": "Im Systembrowser öffnen", "ownedWindow": "Diese Seite wird in einem eigenen Fenster angezeigt.", "ownedWindowShow": "Fenster anzeigen", - "remoteBanner": "Geöffnet über {host}" + "remoteBanner": "Geöffnet über {host}", + "navigationBlocked": "Navigation blockiert: {host}", + "navigationBlockedRule": "durch eine Site-Regel blockiert", + "navigationBlockedScheme": "diese Adressart kann hier nicht geöffnet werden", + "navigationBlockedOpenSystem": "Mit System-App öffnen", + "errorBlockedHint": "Eine Site-Regel blockiert diese Adresse. Regeln werden unter Einstellungen → Integrierter Browser verwaltet.", + "popupDeniedBlockedHost": "durch eine Site-Regel blockiert" }, "tab": { "untitled": "Neuer Tab" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "Im integrierten Browser geöffnet", "firstOpenHint": "⌘/Strg+Klick auf einen Link öffnet ihn stattdessen im Systembrowser. Die Voreinstellung lässt sich in den Einstellungen ändern.", - "useSystemAlways": "Immer den Systembrowser verwenden" + "useSystemAlways": "Immer den Systembrowser verwenden", + "blockedHost": "Durch eine Site-Regel blockiert: {host}" }, "link": { "openBuiltin": "Im integrierten Browser öffnen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ab3f8745f3..adc48a680f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4764,7 +4764,21 @@ "suspendTitle": "Unload background tabs", "suspendHint": "Release the memory of browser tabs that stay in the background for 30 minutes. They load again when you switch back; scroll position and history are lost.", "downloadsTitle": "Downloads", - "downloadsHint": "Files a page downloads are saved to {dir}. Nothing is opened automatically, and an existing file is never replaced." + "downloadsHint": "Files a page downloads are saved to {dir}. Nothing is opened automatically, and an existing file is never replaced.", + "rulesTitle": "Site rules", + "rulesHint": "Per site: always the built-in browser, always the system browser, or block. Patterns are a hostname, *.example.com or *, with an optional :port; the most specific match wins.", + "rulePatternPlaceholder": "example.com or *.example.com", + "rulePatternLabel": "Site pattern", + "ruleActionLabel": "Action", + "ruleActionFor": "Action for {pattern}", + "ruleAdd": "Add", + "ruleRemove": "Remove rule", + "ruleActionBlock": "Block", + "ruleManaged": "Set by your administrator", + "ruleInvalid": "Not a valid pattern", + "ruleDuplicate": "There is already a rule for this pattern", + "rulesEmpty": "No rules yet.", + "managedDisabled": "The built-in browser is turned off by your administrator's policy; links open in the system browser." }, "LogsSettings": { "loading": "Loading…", @@ -5829,7 +5843,13 @@ "openInSystem": "Open in system browser", "ownedWindow": "This page is shown in its own window.", "ownedWindowShow": "Show window", - "remoteBanner": "Opened through {host}" + "remoteBanner": "Opened through {host}", + "navigationBlocked": "Navigation blocked: {host}", + "navigationBlockedRule": "blocked by a site rule", + "navigationBlockedScheme": "address type not allowed here", + "navigationBlockedOpenSystem": "Open with system app", + "errorBlockedHint": "A site rule blocks this address. Rules are managed in Settings → Built-in browser.", + "popupDeniedBlockedHost": "blocked by a site rule" }, "tab": { "untitled": "New tab" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "Opened in the built-in browser", "firstOpenHint": "⌘/Ctrl-click a link to open it in your system browser instead. Change the default in Settings.", - "useSystemAlways": "Always use system browser" + "useSystemAlways": "Always use system browser", + "blockedHost": "Blocked by a site rule: {host}" }, "link": { "openBuiltin": "Open in built-in browser", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b4aa5f9cac..b3a243a8cc 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4764,7 +4764,21 @@ "suspendTitle": "Descargar pestañas en segundo plano", "suspendHint": "Libera la memoria de las pestañas del navegador que llevan 30 minutos en segundo plano. Se vuelven a cargar al regresar; se pierden la posición de desplazamiento y el historial.", "downloadsTitle": "Descargas", - "downloadsHint": "Los archivos que descarga una página se guardan en {dir}. No se abre nada automáticamente y nunca se reemplaza un archivo existente." + "downloadsHint": "Los archivos que descarga una página se guardan en {dir}. No se abre nada automáticamente y nunca se reemplaza un archivo existente.", + "rulesTitle": "Reglas de sitio", + "rulesHint": "Por sitio: siempre el navegador integrado, siempre el navegador del sistema, o bloquear. Los patrones son un nombre de host, *.example.com o *, con un :puerto opcional; gana la coincidencia más específica.", + "rulePatternPlaceholder": "example.com o *.example.com", + "rulePatternLabel": "Patrón de sitio", + "ruleActionLabel": "Acción", + "ruleActionFor": "Acción para {pattern}", + "ruleAdd": "Añadir", + "ruleRemove": "Quitar regla", + "ruleActionBlock": "Bloquear", + "ruleManaged": "Definida por tu administrador", + "ruleInvalid": "No es un patrón válido", + "ruleDuplicate": "Ya existe una regla para este patrón", + "rulesEmpty": "Aún no hay reglas.", + "managedDisabled": "La política de tu administrador ha desactivado el navegador integrado; los enlaces se abren en el navegador del sistema." }, "LogsSettings": { "loading": "Cargando…", @@ -5829,7 +5843,13 @@ "openInSystem": "Abrir en el navegador del sistema", "ownedWindow": "Esta página se muestra en su propia ventana.", "ownedWindowShow": "Mostrar ventana", - "remoteBanner": "Abierto a través de {host}" + "remoteBanner": "Abierto a través de {host}", + "navigationBlocked": "Navegación bloqueada: {host}", + "navigationBlockedRule": "bloqueada por una regla de sitio", + "navigationBlockedScheme": "este tipo de dirección no se puede abrir aquí", + "navigationBlockedOpenSystem": "Abrir con la app del sistema", + "errorBlockedHint": "Una regla de sitio bloquea esta dirección. Las reglas se gestionan en Ajustes → Navegador integrado.", + "popupDeniedBlockedHost": "bloqueada por una regla de sitio" }, "tab": { "untitled": "Nueva pestaña" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "Abierto en el navegador integrado", "firstOpenHint": "Haz ⌘/Ctrl+clic en un enlace para abrirlo en el navegador del sistema. Cambia el valor predeterminado en Ajustes.", - "useSystemAlways": "Usar siempre el navegador del sistema" + "useSystemAlways": "Usar siempre el navegador del sistema", + "blockedHost": "Bloqueado por una regla de sitio: {host}" }, "link": { "openBuiltin": "Abrir en el navegador integrado", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e446e8bdc7..9046dd983d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4764,7 +4764,21 @@ "suspendTitle": "Décharger les onglets en arrière-plan", "suspendHint": "Libère la mémoire des onglets du navigateur restés 30 minutes en arrière-plan. Ils se rechargent quand vous y revenez ; la position de défilement et l’historique sont perdus.", "downloadsTitle": "Téléchargements", - "downloadsHint": "Les fichiers téléchargés par une page sont enregistrés dans {dir}. Rien n’est ouvert automatiquement et aucun fichier existant n’est remplacé." + "downloadsHint": "Les fichiers téléchargés par une page sont enregistrés dans {dir}. Rien n’est ouvert automatiquement et aucun fichier existant n’est remplacé.", + "rulesTitle": "Règles de site", + "rulesHint": "Par site : toujours le navigateur intégré, toujours le navigateur système, ou bloquer. Les motifs sont un nom d'hôte, *.example.com ou *, avec un :port facultatif ; la correspondance la plus précise l'emporte.", + "rulePatternPlaceholder": "example.com ou *.example.com", + "rulePatternLabel": "Motif de site", + "ruleActionLabel": "Action", + "ruleActionFor": "Action pour {pattern}", + "ruleAdd": "Ajouter", + "ruleRemove": "Supprimer la règle", + "ruleActionBlock": "Bloquer", + "ruleManaged": "Définie par votre administrateur", + "ruleInvalid": "Motif non valide", + "ruleDuplicate": "Une règle existe déjà pour ce motif", + "rulesEmpty": "Aucune règle pour l'instant.", + "managedDisabled": "La politique de votre administrateur a désactivé le navigateur intégré ; les liens s'ouvrent dans le navigateur système." }, "LogsSettings": { "loading": "Chargement…", @@ -5829,7 +5843,13 @@ "openInSystem": "Ouvrir dans le navigateur système", "ownedWindow": "Cette page s'affiche dans sa propre fenêtre.", "ownedWindowShow": "Afficher la fenêtre", - "remoteBanner": "Ouvert via {host}" + "remoteBanner": "Ouvert via {host}", + "navigationBlocked": "Navigation bloquée : {host}", + "navigationBlockedRule": "bloquée par une règle de site", + "navigationBlockedScheme": "ce type d'adresse ne peut pas s'ouvrir ici", + "navigationBlockedOpenSystem": "Ouvrir avec l'app système", + "errorBlockedHint": "Une règle de site bloque cette adresse. Les règles se gèrent dans Réglages → Navigateur intégré.", + "popupDeniedBlockedHost": "bloquée par une règle de site" }, "tab": { "untitled": "Nouvel onglet" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "Ouvert dans le navigateur intégré", "firstOpenHint": "⌘/Ctrl+clic sur un lien pour l'ouvrir dans le navigateur système. Le choix par défaut se modifie dans les Réglages.", - "useSystemAlways": "Toujours utiliser le navigateur système" + "useSystemAlways": "Toujours utiliser le navigateur système", + "blockedHost": "Bloqué par une règle de site : {host}" }, "link": { "openBuiltin": "Ouvrir dans le navigateur intégré", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 66a6452f74..87dba32820 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4764,7 +4764,21 @@ "suspendTitle": "バックグラウンドのタブを解放", "suspendHint": "30 分間バックグラウンドにあるブラウザタブのメモリを解放します。戻ると再読み込みされ、スクロール位置と履歴は失われます。", "downloadsTitle": "ダウンロード", - "downloadsHint": "ページからダウンロードしたファイルは {dir} に保存されます。自動的に開かれることはなく、既存のファイルを上書きしません。" + "downloadsHint": "ページからダウンロードしたファイルは {dir} に保存されます。自動的に開かれることはなく、既存のファイルを上書きしません。", + "rulesTitle": "サイトルール", + "rulesHint": "サイトごとに設定: 常に内蔵ブラウザ、常にシステムブラウザ、またはブロック。パターンはホスト名、*.example.com、* のいずれかで、:ポートを付けられます。最も具体的な一致が優先されます。", + "rulePatternPlaceholder": "example.com または *.example.com", + "rulePatternLabel": "サイトのパターン", + "ruleActionLabel": "動作", + "ruleActionFor": "{pattern} の動作", + "ruleAdd": "追加", + "ruleRemove": "ルールを削除", + "ruleActionBlock": "ブロック", + "ruleManaged": "管理者による設定", + "ruleInvalid": "有効なパターンではありません", + "ruleDuplicate": "このパターンのルールは既にあります", + "rulesEmpty": "ルールはまだありません。", + "managedDisabled": "管理者のポリシーにより内蔵ブラウザは無効です。リンクはシステムブラウザで開きます。" }, "LogsSettings": { "loading": "読み込み中…", @@ -5829,7 +5843,13 @@ "openInSystem": "システムのブラウザで開く", "ownedWindow": "このページは別ウィンドウに表示されています。", "ownedWindowShow": "ウィンドウを表示", - "remoteBanner": "{host} 経由で開いています" + "remoteBanner": "{host} 経由で開いています", + "navigationBlocked": "移動をブロックしました: {host}", + "navigationBlockedRule": "サイトルールによりブロック", + "navigationBlockedScheme": "この種類のアドレスはここでは開けません", + "navigationBlockedOpenSystem": "システムアプリで開く", + "errorBlockedHint": "サイトルールがこのアドレスをブロックしています。ルールは「設定 → 内蔵ブラウザ」で管理します。", + "popupDeniedBlockedHost": "サイトルールによりブロック" }, "tab": { "untitled": "新しいタブ" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "内蔵ブラウザで開きました", "firstOpenHint": "⌘/Ctrl を押しながらリンクをクリックするとシステムのブラウザで開きます。既定は設定で変更できます。", - "useSystemAlways": "常にシステムのブラウザを使う" + "useSystemAlways": "常にシステムのブラウザを使う", + "blockedHost": "サイトルールによりブロック: {host}" }, "link": { "openBuiltin": "内蔵ブラウザで開く", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 8f1a6a7ff6..e2fdc9e22d 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4764,7 +4764,21 @@ "suspendTitle": "백그라운드 탭 내려놓기", "suspendHint": "30분 동안 백그라운드에 있는 브라우저 탭의 메모리를 해제합니다. 다시 전환하면 새로 불러오며, 스크롤 위치와 방문 기록은 사라집니다.", "downloadsTitle": "다운로드", - "downloadsHint": "페이지에서 내려받은 파일은 {dir}에 저장됩니다. 자동으로 열지 않으며 기존 파일을 덮어쓰지 않습니다." + "downloadsHint": "페이지에서 내려받은 파일은 {dir}에 저장됩니다. 자동으로 열지 않으며 기존 파일을 덮어쓰지 않습니다.", + "rulesTitle": "사이트 규칙", + "rulesHint": "사이트별 설정: 항상 내장 브라우저, 항상 시스템 브라우저, 또는 차단. 패턴은 호스트 이름, *.example.com 또는 *이며 :포트를 붙일 수 있습니다. 가장 구체적인 일치가 적용됩니다.", + "rulePatternPlaceholder": "example.com 또는 *.example.com", + "rulePatternLabel": "사이트 패턴", + "ruleActionLabel": "동작", + "ruleActionFor": "{pattern}의 동작", + "ruleAdd": "추가", + "ruleRemove": "규칙 삭제", + "ruleActionBlock": "차단", + "ruleManaged": "관리자가 설정함", + "ruleInvalid": "올바른 패턴이 아닙니다", + "ruleDuplicate": "이 패턴에 대한 규칙이 이미 있습니다", + "rulesEmpty": "아직 규칙이 없습니다.", + "managedDisabled": "관리자 정책으로 내장 브라우저가 꺼져 있습니다. 링크는 시스템 브라우저에서 열립니다." }, "LogsSettings": { "loading": "불러오는 중…", @@ -5829,7 +5843,13 @@ "openInSystem": "시스템 브라우저에서 열기", "ownedWindow": "이 페이지는 별도의 창에 표시됩니다.", "ownedWindowShow": "창 표시", - "remoteBanner": "{host}을(를) 통해 열림" + "remoteBanner": "{host}을(를) 통해 열림", + "navigationBlocked": "이동이 차단됨: {host}", + "navigationBlockedRule": "사이트 규칙으로 차단됨", + "navigationBlockedScheme": "이 종류의 주소는 여기서 열 수 없습니다", + "navigationBlockedOpenSystem": "시스템 앱으로 열기", + "errorBlockedHint": "사이트 규칙이 이 주소를 차단합니다. 규칙은 설정 → 내장 브라우저에서 관리합니다.", + "popupDeniedBlockedHost": "사이트 규칙으로 차단됨" }, "tab": { "untitled": "새 탭" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "내장 브라우저에서 열었습니다", "firstOpenHint": "⌘/Ctrl을 누른 채 링크를 클릭하면 시스템 브라우저에서 열립니다. 기본값은 설정에서 바꿀 수 있습니다.", - "useSystemAlways": "항상 시스템 브라우저 사용" + "useSystemAlways": "항상 시스템 브라우저 사용", + "blockedHost": "사이트 규칙으로 차단됨: {host}" }, "link": { "openBuiltin": "내장 브라우저에서 열기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 59c81dcb3c..6f4055671f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4764,7 +4764,21 @@ "suspendTitle": "Descarregar abas em segundo plano", "suspendHint": "Libera a memória das abas do navegador que ficam 30 minutos em segundo plano. Elas recarregam quando você volta; a posição de rolagem e o histórico são perdidos.", "downloadsTitle": "Downloads", - "downloadsHint": "Os arquivos baixados por uma página são salvos em {dir}. Nada é aberto automaticamente e um arquivo existente nunca é substituído." + "downloadsHint": "Os arquivos baixados por uma página são salvos em {dir}. Nada é aberto automaticamente e um arquivo existente nunca é substituído.", + "rulesTitle": "Regras de site", + "rulesHint": "Por site: sempre o navegador integrado, sempre o navegador do sistema ou bloquear. Os padrões são um nome de host, *.example.com ou *, com :porta opcional; a correspondência mais específica vence.", + "rulePatternPlaceholder": "example.com ou *.example.com", + "rulePatternLabel": "Padrão de site", + "ruleActionLabel": "Ação", + "ruleActionFor": "Ação para {pattern}", + "ruleAdd": "Adicionar", + "ruleRemove": "Remover regra", + "ruleActionBlock": "Bloquear", + "ruleManaged": "Definida pelo seu administrador", + "ruleInvalid": "Não é um padrão válido", + "ruleDuplicate": "Já existe uma regra para este padrão", + "rulesEmpty": "Ainda não há regras.", + "managedDisabled": "A política do seu administrador desativou o navegador integrado; os links abrem no navegador do sistema." }, "LogsSettings": { "loading": "Carregando…", @@ -5829,7 +5843,13 @@ "openInSystem": "Abrir no navegador do sistema", "ownedWindow": "Esta página é exibida em uma janela própria.", "ownedWindowShow": "Mostrar janela", - "remoteBanner": "Aberto via {host}" + "remoteBanner": "Aberto via {host}", + "navigationBlocked": "Navegação bloqueada: {host}", + "navigationBlockedRule": "bloqueada por uma regra de site", + "navigationBlockedScheme": "este tipo de endereço não pode ser aberto aqui", + "navigationBlockedOpenSystem": "Abrir com o app do sistema", + "errorBlockedHint": "Uma regra de site bloqueia este endereço. As regras são geridas em Configurações → Navegador integrado.", + "popupDeniedBlockedHost": "bloqueada por uma regra de site" }, "tab": { "untitled": "Nova aba" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "Aberto no navegador integrado", "firstOpenHint": "⌘/Ctrl+clique em um link para abri-lo no navegador do sistema. Altere o padrão em Configurações.", - "useSystemAlways": "Sempre usar o navegador do sistema" + "useSystemAlways": "Sempre usar o navegador do sistema", + "blockedHost": "Bloqueado por uma regra de site: {host}" }, "link": { "openBuiltin": "Abrir no navegador integrado", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f04166b0c4..736f379367 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4764,7 +4764,21 @@ "suspendTitle": "卸载后台标签页", "suspendHint": "释放在后台停留 30 分钟的浏览器标签页所占的内存。切回时会重新加载;滚动位置和历史记录会丢失。", "downloadsTitle": "下载", - "downloadsHint": "网页下载的文件保存在 {dir}。不会自动打开,也不会覆盖已有文件。" + "downloadsHint": "网页下载的文件保存在 {dir}。不会自动打开,也不会覆盖已有文件。", + "rulesTitle": "站点规则", + "rulesHint": "按站点设定:始终用内置浏览器、始终用系统浏览器,或阻止。模式可以是主机名、*.example.com 或 *,可选加 :端口;最具体的匹配生效。", + "rulePatternPlaceholder": "example.com 或 *.example.com", + "rulePatternLabel": "站点模式", + "ruleActionLabel": "动作", + "ruleActionFor": "{pattern} 的动作", + "ruleAdd": "添加", + "ruleRemove": "删除规则", + "ruleActionBlock": "阻止", + "ruleManaged": "由管理员设定", + "ruleInvalid": "不是有效的模式", + "ruleDuplicate": "已有针对该模式的规则", + "rulesEmpty": "还没有规则。", + "managedDisabled": "管理员策略已关闭内置浏览器;链接将在系统浏览器中打开。" }, "LogsSettings": { "loading": "加载中…", @@ -5829,7 +5843,13 @@ "openInSystem": "在系统浏览器中打开", "ownedWindow": "该页面显示在独立窗口中。", "ownedWindowShow": "显示窗口", - "remoteBanner": "经由 {host} 打开" + "remoteBanner": "经由 {host} 打开", + "navigationBlocked": "已阻止跳转:{host}", + "navigationBlockedRule": "被站点规则阻止", + "navigationBlockedScheme": "此类地址不能在这里打开", + "navigationBlockedOpenSystem": "用系统应用打开", + "errorBlockedHint": "站点规则阻止了这个地址。规则在「设置 → 内置浏览器」中管理。", + "popupDeniedBlockedHost": "被站点规则阻止" }, "tab": { "untitled": "新标签页" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "已在内置浏览器中打开", "firstOpenHint": "按住 ⌘/Ctrl 点击链接可改用系统浏览器。默认方式可在设置中修改。", - "useSystemAlways": "始终使用系统浏览器" + "useSystemAlways": "始终使用系统浏览器", + "blockedHost": "被站点规则阻止:{host}" }, "link": { "openBuiltin": "在内置浏览器中打开", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index ec72e127e6..dc47343fc9 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4764,7 +4764,21 @@ "suspendTitle": "卸載背景分頁", "suspendHint": "釋放在背景停留 30 分鐘的瀏覽器分頁所佔的記憶體。切回時會重新載入;捲動位置與歷史記錄會遺失。", "downloadsTitle": "下載", - "downloadsHint": "網頁下載的檔案儲存於 {dir}。不會自動開啟,也不會覆蓋既有檔案。" + "downloadsHint": "網頁下載的檔案儲存於 {dir}。不會自動開啟,也不會覆蓋既有檔案。", + "rulesTitle": "站點規則", + "rulesHint": "按站點設定:一律用內建瀏覽器、一律用系統瀏覽器,或阻止。模式可以是主機名稱、*.example.com 或 *,可選加 :連接埠;最具體的符合項生效。", + "rulePatternPlaceholder": "example.com 或 *.example.com", + "rulePatternLabel": "站點模式", + "ruleActionLabel": "動作", + "ruleActionFor": "{pattern} 的動作", + "ruleAdd": "新增", + "ruleRemove": "刪除規則", + "ruleActionBlock": "阻止", + "ruleManaged": "由管理員設定", + "ruleInvalid": "不是有效的模式", + "ruleDuplicate": "已有針對該模式的規則", + "rulesEmpty": "還沒有規則。", + "managedDisabled": "管理員原則已關閉內建瀏覽器;連結將在系統瀏覽器中開啟。" }, "LogsSettings": { "loading": "載入中…", @@ -5829,7 +5843,13 @@ "openInSystem": "在系統瀏覽器中開啟", "ownedWindow": "此頁面顯示在獨立視窗中。", "ownedWindowShow": "顯示視窗", - "remoteBanner": "經由 {host} 開啟" + "remoteBanner": "經由 {host} 開啟", + "navigationBlocked": "已阻止跳轉:{host}", + "navigationBlockedRule": "被站點規則阻止", + "navigationBlockedScheme": "此類位址不能在這裡開啟", + "navigationBlockedOpenSystem": "用系統應用程式開啟", + "errorBlockedHint": "站點規則阻止了這個位址。規則在「設定 → 內建瀏覽器」中管理。", + "popupDeniedBlockedHost": "被站點規則阻止" }, "tab": { "untitled": "新分頁" @@ -5857,7 +5877,8 @@ "toast": { "firstOpen": "已在內建瀏覽器中開啟", "firstOpenHint": "按住 ⌘/Ctrl 點擊連結可改用系統瀏覽器。預設方式可在設定中修改。", - "useSystemAlways": "一律使用系統瀏覽器" + "useSystemAlways": "一律使用系統瀏覽器", + "blockedHost": "被站點規則阻止:{host}" }, "link": { "openBuiltin": "在內建瀏覽器中開啟", diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index 970a25e197..8fcc6703e7 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -5,11 +5,13 @@ import { getTransport, isDesktop } from "@/lib/transport" +import type { HostRule } from "./host-rules" import type { Bounds, BrowserCapabilities, BrowserDownload, BrowserTabState, + FrozenFrame, SurfaceChoice, } from "./types" @@ -22,6 +24,7 @@ const UNAVAILABLE: BrowserCapabilities = { isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "", + policy: { enabled: false, managedRules: [], managedSource: null }, } let capabilitiesPromise: Promise<BrowserCapabilities> | null = null @@ -116,15 +119,34 @@ export function browserSetBounds(tabId: string, bounds: Bounds): Promise<void> { return getTransport().call<void>("browser_set_bounds", { tabId, bounds }) } +/** + * Show or hide a tab's surface. Hiding with `freeze` asks for the frame the + * page shows at that moment, to paint in the placeholder while an overlay is + * open over it; `null` when the platform cannot provide one in time. + */ export function browserSetVisible( tabId: string, visible: boolean, - handoffFocus = false -): Promise<void> { - return getTransport().call<void>("browser_set_visible", { - tabId, - visible, - handoffFocus, + handoffFocus = false, + freeze = false +): Promise<FrozenFrame | null> { + return getTransport() + .call<FrozenFrame | null | undefined>("browser_set_visible", { + tabId, + visible, + handoffFocus, + freeze, + }) + .then((frame) => frame ?? null) +} + +/** The user's site rules, for the backend to enforce `block` on navigations. */ +export function browserSetHostRules(rules: readonly HostRule[]): Promise<void> { + return getTransport().call<void>("browser_set_host_rules", { + rules: rules.map((rule) => ({ + pattern: rule.pattern, + action: rule.action, + })), }) } diff --git a/src/lib/browser/browser-prefs.test.ts b/src/lib/browser/browser-prefs.test.ts index 517d1bcca7..1050a6ee34 100644 --- a/src/lib/browser/browser-prefs.test.ts +++ b/src/lib/browser/browser-prefs.test.ts @@ -8,12 +8,24 @@ import { resetBrowserPrefsForTests, setAllDefaultLinkTargets, setBrowserDevtools, + setBrowserHostRules, setBrowserSurfaceOverride, setDefaultLinkTarget, subscribeBrowserPrefs, useBrowserPrefs, } from "./browser-prefs" +/** Invalidate the in-memory snapshot the way a cross-window change does, + * without touching storage. The subscription that clears the cache only + * exists while someone listens, hence the throwaway subscriber. */ +function resetCacheOnly() { + const unsubscribe = subscribeBrowserPrefs(() => {}) + window.dispatchEvent( + new StorageEvent("storage", { key: "browser:host-rules" }) + ) + unsubscribe() +} + describe("browser prefs", () => { beforeEach(() => { resetBrowserPrefsForTests() @@ -103,6 +115,41 @@ describe("browser prefs", () => { expect(listener).toHaveBeenCalledTimes(2) }) + it("stores the site-rule table under one key and drops junk on read", () => { + expect(getBrowserPrefs().hostRules).toEqual([]) + setBrowserHostRules([ + { pattern: "*.corp.example", action: "builtin" }, + { pattern: "blocked.example", action: "block" }, + ]) + expect(getBrowserPrefs().hostRules).toEqual([ + { pattern: "*.corp.example", action: "builtin" }, + { pattern: "blocked.example", action: "block" }, + ]) + expect(localStorage.getItem("browser:host-rules")).not.toBeNull() + + // A hand-edited or corrupt entry costs that entry, not the table. + localStorage.setItem( + "browser:host-rules", + JSON.stringify([ + { pattern: "ok.example", action: "system" }, + { pattern: "", action: "block" }, + { pattern: "x.example", action: "explode" }, + "junk", + ]) + ) + resetCacheOnly() + expect(getBrowserPrefs().hostRules).toEqual([ + { pattern: "ok.example", action: "system" }, + ]) + localStorage.setItem("browser:host-rules", "{ not json") + resetCacheOnly() + expect(getBrowserPrefs().hostRules).toEqual([]) + + // An empty table removes the key rather than storing `[]`. + setBrowserHostRules([]) + expect(localStorage.getItem("browser:host-rules")).toBeNull() + }) + it("useBrowserPrefs re-renders on change", () => { const { result } = renderHook(() => useBrowserPrefs()) expect(result.current.defaultTarget.toolCard).toBe("builtin") diff --git a/src/lib/browser/browser-prefs.ts b/src/lib/browser/browser-prefs.ts index d190b22c46..972cdf870b 100644 --- a/src/lib/browser/browser-prefs.ts +++ b/src/lib/browser/browser-prefs.ts @@ -7,6 +7,8 @@ import { useSyncExternalStore } from "react" +import { isHostRule, type HostRule } from "./host-rules" + /** Where a clicked address came from; each source carries its own default. */ export type LinkSource = | "transcript" @@ -39,6 +41,9 @@ export interface BrowserPrefsSnapshot { * while (they reload when shown again). Off by default: a page's state * is worth more than its memory unless the user says otherwise. */ suspendBackgroundTabs: boolean + /** Per-site overrides of the default target, and outright blocks. The + * administrator's rules (from the backend's policy) are not in here. */ + hostRules: readonly HostRule[] } export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ @@ -53,6 +58,7 @@ export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ surfaceOverride: "auto", firstOpenSeen: false, suspendBackgroundTabs: false, + hostRules: Object.freeze([]) as readonly HostRule[], }) as BrowserPrefsSnapshot const KEY_PREFIX = "browser:" @@ -65,6 +71,9 @@ const DEVTOOLS_KEY = `${KEY_PREFIX}devtools` const SURFACE_KEY = `${KEY_PREFIX}surface-override` const FIRST_OPEN_KEY = `${KEY_PREFIX}first-open-seen` const SUSPEND_KEY = `${KEY_PREFIX}suspend-background-tabs` +// One key for the whole table: a rule list is one setting, edited in one +// place, and half a table is not a meaningful state. +const HOST_RULES_KEY = `${KEY_PREFIX}host-rules` function readRaw(key: string): string | null { if (typeof window === "undefined") return null @@ -83,6 +92,20 @@ function parseSurface(raw: string | null): SurfaceOverride | null { return raw === "auto" || raw === "child" || raw === "window" ? raw : null } +/** Stored rules, one bad entry dropped rather than the whole list. */ +function parseHostRules(raw: string | null): readonly HostRule[] { + if (!raw) return DEFAULT_BROWSER_PREFS.hostRules + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return DEFAULT_BROWSER_PREFS.hostRules + return parsed + .filter(isHostRule) + .map((rule) => ({ pattern: rule.pattern, action: rule.action })) + } catch { + return DEFAULT_BROWSER_PREFS.hostRules + } +} + function read(): BrowserPrefsSnapshot { const defaultTarget = {} as Record<LinkSource, LinkTarget> for (const source of LINK_SOURCES) { @@ -98,6 +121,7 @@ function read(): BrowserPrefsSnapshot { DEFAULT_BROWSER_PREFS.surfaceOverride, firstOpenSeen: readRaw(FIRST_OPEN_KEY) === "true", suspendBackgroundTabs: readRaw(SUSPEND_KEY) === "true", + hostRules: parseHostRules(readRaw(HOST_RULES_KEY)), } } @@ -149,6 +173,14 @@ export function setBrowserSuspendBackgroundTabs(enabled: boolean): void { write(SUSPEND_KEY, enabled ? "true" : null) } +/** Replace the site-rule table (an empty table removes the key). */ +export function setBrowserHostRules(rules: readonly HostRule[]): void { + const cleaned = rules + .filter(isHostRule) + .map((rule) => ({ pattern: rule.pattern, action: rule.action })) + write(HOST_RULES_KEY, cleaned.length > 0 ? JSON.stringify(cleaned) : null) +} + export function subscribeBrowserPrefs(listener: () => void): () => void { if (typeof window === "undefined") return () => {} const onChange = () => listener() @@ -191,6 +223,7 @@ export function resetBrowserPrefsForTests(): void { localStorage.removeItem(SURFACE_KEY) localStorage.removeItem(FIRST_OPEN_KEY) localStorage.removeItem(SUSPEND_KEY) + localStorage.removeItem(HOST_RULES_KEY) } catch { /* ignore */ } diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index 328af44ca1..fcaca096f0 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -9,7 +9,7 @@ import { useSyncExternalStore } from "react" import { browserClose } from "./browser-api" -import type { BrowserTabState } from "./types" +import type { BrowserTabState, NavigationBlockReason } from "./types" import { buildFileTabId } from "@/lib/file-tab-id" import { isDesktop } from "@/lib/transport" @@ -190,11 +190,10 @@ export function releaseBrowserTab(workspaceTabId: string): void { } /** A transient, dismissible message shown between the toolbar and the page. */ -export interface BrowserTabNotice { - kind: "popup-denied" - url: string - reason: string | null -} +export type BrowserTabNotice = + | { kind: "popup-denied"; url: string; reason: string | null } + /** A top-level navigation the tab attempted was refused by policy. */ + | { kind: "navigation-blocked"; url: string; reason: NavigationBlockReason } const notices = new Map<string, BrowserTabNotice>() diff --git a/src/lib/browser/host-rules.test.ts b/src/lib/browser/host-rules.test.ts new file mode 100644 index 0000000000..49e625a1bc --- /dev/null +++ b/src/lib/browser/host-rules.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest" + +import { + isHostRule, + matchHostRule, + normalizeHostRulePattern, + parseHostRulePattern, + validateHostRulePattern, + type HostRule, +} from "./host-rules" + +// Mirror of the table in src-tauri/src/browser/policy.rs: the two sides must +// accept the same patterns and pick the same rule. +describe("host rule patterns", () => { + it("accepts hostnames, wildcards, ports and IPv6 literals", () => { + for (const ok of [ + "example.com", + "EXAMPLE.com", + " example.com ", + "*.example.com", + "*", + "localhost:3000", + "*.corp.example:8443", + "[::1]:3000", + "[::1]", + "127.0.0.1", + "10.0.0.1:8080", + "*:443", + ]) { + expect(validateHostRulePattern(ok), ok).toBeNull() + } + }) + + it("refuses URLs, paths, bad ports and malformed hosts", () => { + expect(validateHostRulePattern("")).toBe("empty") + expect(validateHostRulePattern(" ")).toBe("empty") + for (const bad of [ + "https://example.com", + "example.com/path", + "example.com:", + "example.com:0", + "example.com:70000", + "example.com:80a", + "*.", + "*example.com", + "a b.com", + ".example.com", + "example..com", + "[::1", + "[::1]x", + "*.*", + ]) { + expect(validateHostRulePattern(bad), bad).toBe("invalid") + expect(parseHostRulePattern(bad), bad).toBeNull() + } + }) + + it("normalizes to trimmed lower case", () => { + expect(normalizeHostRulePattern(" Example.COM:8080 ")).toBe( + "example.com:8080" + ) + }) + + it("recognizes stored rules and rejects junk", () => { + expect(isHostRule({ pattern: "a.example", action: "block" })).toBe(true) + expect(isHostRule({ pattern: "a.example", action: "explode" })).toBe(false) + expect(isHostRule({ pattern: "", action: "block" })).toBe(false) + expect(isHostRule("a.example")).toBe(false) + expect(isHostRule(null)).toBe(false) + }) +}) + +describe("matchHostRule", () => { + const block: HostRule[] = [{ pattern: "*.example.com", action: "block" }] + + it("wildcard semantics", () => { + expect( + matchHostRule(block, new URL("https://a.example.com/")) + ).not.toBeNull() + expect( + matchHostRule(block, new URL("https://a.b.example.com/")) + ).not.toBeNull() + expect(matchHostRule(block, new URL("https://example.com/"))).toBeNull() + expect(matchHostRule(block, new URL("https://notexample.com/"))).toBeNull() + expect( + matchHostRule([{ pattern: "*", action: "system" }], new URL("http://x/")) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "[::1]:3000", action: "builtin" }], + new URL("http://[::1]:3000/") + ) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "[::1]:3000", action: "builtin" }], + new URL("http://[::1]:3001/") + ) + ).toBeNull() + }) + + it("ports pin a rule and compare against the scheme's default", () => { + expect( + matchHostRule( + [{ pattern: "example.com:443", action: "builtin" }], + new URL("https://EXAMPLE.com/") + ) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "example.com:80", action: "builtin" }], + new URL("https://example.com/") + ) + ).toBeNull() + expect( + matchHostRule( + [{ pattern: "example.com:80", action: "builtin" }], + new URL("http://example.com/") + ) + ).not.toBeNull() + }) + + it("never matches an unparsable pattern", () => { + expect( + matchHostRule( + [{ pattern: "https://example.com", action: "block" }], + new URL("https://example.com/") + ) + ).toBeNull() + expect(matchHostRule([], new URL("https://example.com/"))).toBeNull() + expect(matchHostRule(undefined, new URL("https://example.com/"))).toBeNull() + }) + + it("picks the most specific rule regardless of order, first listed on a tie", () => { + const rules: HostRule[] = [ + { pattern: "*", action: "system" }, + { pattern: "*.corp.example", action: "builtin" }, + { pattern: "*.corp.example:8443", action: "system" }, + { pattern: "sso.corp.example", action: "block" }, + { pattern: "*.sso.corp.example", action: "builtin" }, + ] + const action = (url: string) => + matchHostRule(rules, new URL(url))?.action ?? null + expect(action("https://sso.corp.example/")).toBe("block") + expect(action("https://sso.corp.example:8443/")).toBe("block") + expect(action("https://wiki.corp.example:8443/")).toBe("system") + expect(action("https://wiki.corp.example/")).toBe("builtin") + expect(action("https://a.sso.corp.example/")).toBe("builtin") + expect(action("https://elsewhere.example/")).toBe("system") + expect( + matchHostRule( + [ + { pattern: "dup.example", action: "builtin" }, + { pattern: "dup.example", action: "block" }, + ], + new URL("https://dup.example/") + )?.action + ).toBe("builtin") + }) +}) diff --git a/src/lib/browser/host-rules.ts b/src/lib/browser/host-rules.ts new file mode 100644 index 0000000000..df63cda939 --- /dev/null +++ b/src/lib/browser/host-rules.ts @@ -0,0 +1,193 @@ +// Site rules of the built-in browser: "always open this host in the built-in +// browser / in the system browser / never". The matching here decides which +// rule applies to a URL; the same algorithm runs in Rust +// (`src-tauri/src/browser/policy.rs`), which enforces `block` on every +// navigation a tab attempts, so both sides must stay identical. + +import type { LinkTarget } from "./browser-prefs" + +export type HostRuleAction = LinkTarget | "block" + +export const HOST_RULE_ACTIONS: readonly HostRuleAction[] = [ + "builtin", + "system", + "block", +] + +export interface HostRule { + /** Hostname, `*.suffix`, or `*`; an optional `:port` pins the port. */ + pattern: string + action: HostRuleAction +} + +/** Longest a hostname can be (RFC 1035), plus a port. */ +const MAX_PATTERN_LEN = 253 + 6 + +type HostMatcher = + | { kind: "any" } + | { kind: "suffix"; suffix: string } + | { kind: "exact"; host: string } + +export interface ParsedHostRulePattern { + host: HostMatcher + port: number | null +} + +function validHostname(host: string): boolean { + return ( + host.length > 0 && + !host.startsWith(".") && + !host.endsWith(".") && + !host.includes("..") && + /^[a-z0-9._-]+$/.test(host) + ) +} + +function validIpv6(host: string): boolean { + return host.includes(":") && /^[0-9a-f:.]+$/.test(host) +} + +/** + * Parse a pattern; `null` for anything that is not one. Case-insensitive, + * surrounding whitespace ignored. Same grammar as the Rust side. + */ +export function parseHostRulePattern( + pattern: string +): ParsedHostRulePattern | null { + const trimmed = pattern.trim().toLowerCase() + if (!trimmed || trimmed.length > MAX_PATTERN_LEN) return null + let host: string + let portText: string | null = null + if (trimmed.startsWith("[")) { + // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows. + const close = trimmed.indexOf("]") + if (close === -1) return null + host = trimmed.slice(1, close) + const tail = trimmed.slice(close + 1) + if (tail.startsWith(":")) portText = tail.slice(1) + else if (tail.length > 0) return null + } else { + const colon = trimmed.lastIndexOf(":") + if (colon !== -1) { + const digits = trimmed.slice(colon + 1) + if (!/^\d+$/.test(digits)) return null + host = trimmed.slice(0, colon) + portText = digits + } else { + host = trimmed + } + } + let port: number | null = null + if (portText !== null) { + port = Number(portText) + if (!Number.isInteger(port) || port < 1 || port > 65535) return null + } + let matcher: HostMatcher + if (host === "*") { + matcher = { kind: "any" } + } else if (host.startsWith("*.")) { + const suffix = host.slice(2) + if (!validHostname(suffix)) return null + matcher = { kind: "suffix", suffix: `.${suffix}` } + } else if (validHostname(host) || validIpv6(host)) { + matcher = { kind: "exact", host } + } else { + return null + } + return { host: matcher, port } +} + +/** Why a typed pattern is not accepted, or `null` when it is. */ +export function validateHostRulePattern( + pattern: string +): "empty" | "invalid" | null { + if (!pattern.trim()) return "empty" + return parseHostRulePattern(pattern) ? null : "invalid" +} + +/** The form a pattern is stored in: trimmed and lower-cased. */ +export function normalizeHostRulePattern(pattern: string): string { + return pattern.trim().toLowerCase() +} + +function effectivePort(parsed: URL): number | null { + if (parsed.port) return Number(parsed.port) + if (parsed.protocol === "https:") return 443 + if (parsed.protocol === "http:") return 80 + return null +} + +function matches( + rule: ParsedHostRulePattern, + hostname: string, + port: number | null +): boolean { + const host = rule.host + const hostOk = + host.kind === "any" + ? true + : host.kind === "suffix" + ? hostname.endsWith(host.suffix) && hostname.length > host.suffix.length + : hostname === host.host + return hostOk && (rule.port === null || rule.port === port) +} + +/** + * Higher wins: an exact host over a wildcard, a longer wildcard suffix over + * a shorter one, `*` last; a pinned port breaks a tie. + */ +function specificity(rule: ParsedHostRulePattern): [number, number, number] { + const host = rule.host + const [kind, len] = + host.kind === "exact" + ? [2, host.host.length] + : host.kind === "suffix" + ? [1, host.suffix.length] + : [0, 0] + return [kind, len, rule.port === null ? 0 : 1] +} + +function moreSpecific( + a: [number, number, number], + b: [number, number, number] +): boolean { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] > b[i] + } + return false +} + +/** + * The rule that applies to `parsed`: the most specific matching pattern, and + * among equally specific ones the first listed. Unparsable patterns never + * match. + */ +export function matchHostRule( + rules: readonly HostRule[] | undefined, + parsed: URL +): HostRule | null { + if (!rules || rules.length === 0) return null + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() + const port = effectivePort(parsed) + let best: { rule: HostRule; score: [number, number, number] } | null = null + for (const rule of rules) { + const pattern = parseHostRulePattern(rule.pattern) + if (!pattern || !matches(pattern, hostname, port)) continue + const score = specificity(pattern) + if (!best || moreSpecific(score, best.score)) best = { rule, score } + } + return best?.rule ?? null +} + +/** Whether a value read from storage or the wire is a rule. */ +export function isHostRule(value: unknown): value is HostRule { + if (!value || typeof value !== "object") return false + const candidate = value as { pattern?: unknown; action?: unknown } + return ( + typeof candidate.pattern === "string" && + candidate.pattern.trim().length > 0 && + candidate.pattern.length <= MAX_PATTERN_LEN && + typeof candidate.action === "string" && + (HOST_RULE_ACTIONS as readonly string[]).includes(candidate.action) + ) +} diff --git a/src/lib/browser/types.test.ts b/src/lib/browser/types.test.ts index 93e3cdb99f..33a7a18015 100644 --- a/src/lib/browser/types.test.ts +++ b/src/lib/browser/types.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest" import type { BrowserCapabilities, + BrowserNavigationBlockedPayload, BrowserPopupPayload, BrowserTabState, + FrozenFrame, } from "./types" // These literals are copied from what the Rust side serializes (see the @@ -47,6 +49,11 @@ describe("browser wire types", () => { reason: null, }, downloadsDir: "/Users/dev/Downloads", + policy: { + enabled: true, + managedRules: [{ pattern: "*.internal.example", action: "block" }], + managedSource: "/etc/codeg/policy.json", + }, } satisfies BrowserCapabilities const popup = { presentation: "adopted", @@ -58,4 +65,19 @@ describe("browser wire types", () => { } satisfies BrowserPopupPayload expect(caps.available && popup.presentation === "adopted").toBe(true) }) + + it("matches the Rust serialization of the blocked-navigation event and the freeze frame", () => { + const blocked = { + tabId: "t1", + url: "https://blocked.example/", + reason: "host-rule", + } satisfies BrowserNavigationBlockedPayload + const frame = { + mime: "image/jpeg", + data: "AAAA", + width: 10, + height: 4, + } satisfies FrozenFrame + expect(blocked.reason === "host-rule" && frame.mime).toBe("image/jpeg") + }) }) diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index ea4a9f7586..6c6aa2bb06 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -66,6 +66,22 @@ export interface BrowserProxyStatus { reason: string | null } +/** A site rule as the backend sees it (same shape as `host-rules.ts`). */ +export interface WireHostRule { + pattern: string + action: "builtin" | "system" | "block" +} + +/** The administrator's policy in force. */ +export interface BrowserPolicyStatus { + /** `false`: the built-in browser is turned off machine-wide. */ + enabled: boolean + /** Rules fixed by the administrator; shown read-only, consulted first. */ + managedRules: WireHostRule[] + /** Path of the policy file, when one was read. */ + managedSource: string | null +} + export interface BrowserCapabilities { available: boolean surface: SurfaceKind | null @@ -77,6 +93,17 @@ export interface BrowserCapabilities { proxy: BrowserProxyStatus /** Absolute path a page's downloads land in. */ downloadsDir: string + policy: BrowserPolicyStatus +} + +/** The last frame of a page, returned by a hide-with-freeze: the placeholder + * paints it while the surface is hidden under an overlay. */ +export interface FrozenFrame { + mime: string + /** Base64 of the encoded image. */ + data: string + width: number + height: number } export type BrowserDownloadState = "started" | "completed" | "failed" @@ -138,6 +165,17 @@ export const BROWSER_POPUP_EVENT = "browser://popup" export const BROWSER_TELEMETRY_EVENT = "browser://telemetry" export const BROWSER_DOWNLOAD_EVENT = "browser://download" export const BROWSER_SHORTCUT_EVENT = "browser://shortcut" +export const BROWSER_NAVIGATION_BLOCKED_EVENT = "browser://navigation-blocked" + +export type NavigationBlockReason = "host-rule" | "scheme" + +/** `browser://navigation-blocked`: a top-level navigation a tab attempted + * was refused by policy; the tab itself is unchanged. */ +export interface BrowserNavigationBlockedPayload { + tabId: string + url: string + reason: NavigationBlockReason +} /** A browser shortcut the page had keyboard focus for. */ export interface BrowserShortcutPayload { diff --git a/src/lib/resolve-link-action.test.ts b/src/lib/resolve-link-action.test.ts index 50a15fdbf8..541541b2c3 100644 --- a/src/lib/resolve-link-action.test.ts +++ b/src/lib/resolve-link-action.test.ts @@ -227,13 +227,52 @@ describe("resolveLinkAction — host rules", () => { ).toMatchObject({ kind: "system" }) }) - it("first matching rule wins and ports pin a rule", () => { + it("the most specific rule wins and ports pin a rule", () => { expect( resolveLinkAction("https://sso.corp.example:8443/", ctx({ hostRules })) ).toMatchObject({ kind: "system" }) expect( resolveLinkAction("https://sso.corp.example/", ctx({ hostRules })) ).toMatchObject({ kind: "builtin" }) + // Order in the table does not matter: an exact host beats a wildcard. + const reversed = [ + { pattern: "*.corp.example", action: "system" as const }, + { pattern: "wiki.corp.example", action: "builtin" as const }, + ] + expect( + resolveLinkAction( + "https://wiki.corp.example/", + ctx({ hostRules: reversed }) + ) + ).toMatchObject({ kind: "builtin" }) + }) + + it("the administrator's rules are consulted before the user's", () => { + const managedHostRules = [ + { pattern: "*.internal.example", action: "block" as const }, + ] + // A more specific user rule does not lift a managed block … + expect( + resolveLinkAction( + "https://wiki.internal.example/", + ctx({ + managedHostRules, + hostRules: [ + { pattern: "wiki.internal.example", action: "builtin" as const }, + ], + }) + ) + ).toMatchObject({ kind: "reject", reason: "blocked-host" }) + // … and a host the managed table says nothing about falls through. + expect( + resolveLinkAction( + "https://example.com/", + ctx({ + managedHostRules, + hostRules: [{ pattern: "example.com", action: "system" as const }], + }) + ) + ).toMatchObject({ kind: "system" }) }) it("matchHostRule: wildcard semantics", () => { diff --git a/src/lib/resolve-link-action.ts b/src/lib/resolve-link-action.ts index ba1c2294e4..5a3e9c2e1d 100644 --- a/src/lib/resolve-link-action.ts +++ b/src/lib/resolve-link-action.ts @@ -10,11 +10,12 @@ // local path still opens the file panel, `mailto:`/`tel:` still go to the // OS, and an unknown scheme (`vscode:`, `javascript:` …) is still refused // rather than handed to the OS. -// 2. terminal rules — a `block` host rule, or a loopback/private address seen -// from a window bound to a REMOTE codeg-server. These cannot be inverted -// by the modifier key: flipping a remote `localhost:3000` to the system -// browser would only ever hit the local machine's loopback. -// 3. base target — an explicit menu choice, else a host rule, else the +// 2. terminal rules — a `block` site rule (the administrator's table is +// consulted before the user's), or a loopback/private address seen from a +// window bound to a REMOTE codeg-server. These cannot be inverted by the +// modifier key: flipping a remote `localhost:3000` to the system browser +// would only ever hit the local machine's loopback. +// 3. base target — an explicit menu choice, else a site rule, else the // per-source preference; without a built-in browser it is always `system`. // 4. modifier — ⌘ (macOS) / Ctrl (elsewhere) inverts an ordinary preference. // 5. placement — the file column when it is on screen, else the transcript's @@ -26,13 +27,11 @@ import type { LinkSource, LinkTarget, } from "@/lib/browser/browser-prefs" +import { matchHostRule, type HostRule } from "@/lib/browser/host-rules" import { classifyLinkTarget, type LocalFileTarget } from "@/lib/link-classify" -export interface HostRule { - /** Hostname, `*.suffix`, or `*`; an optional `:port` pins the port. */ - pattern: string - action: LinkTarget | "block" -} +export { matchHostRule } +export type { HostRule } export interface LinkSurface { /** `browser_capabilities().available` on desktop; false in web mode. */ @@ -51,7 +50,11 @@ export interface ResolveLinkContext { modifier: boolean surface: LinkSurface prefs: Pick<BrowserPrefsSnapshot, "defaultTarget"> + /** The user's site rules. */ hostRules?: readonly HostRule[] + /** The administrator's site rules: consulted first, whatever the user's + * table says about the same host. */ + managedHostRules?: readonly HostRule[] /** An explicit user choice (context menu). Replaces the preference and * ignores the modifier, but still yields to the terminal rules. */ forceTarget?: LinkTarget @@ -75,55 +78,6 @@ export type LinkAction = remoteOverride: boolean } -function effectivePort(parsed: URL): string { - if (parsed.port) return parsed.port - return parsed.protocol === "https:" ? "443" : "80" -} - -function splitPattern(pattern: string): { host: string; port: string | null } { - const trimmed = pattern.trim().toLowerCase() - // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows them. - if (trimmed.startsWith("[")) { - const close = trimmed.indexOf("]") - if (close === -1) return { host: trimmed, port: null } - const host = trimmed.slice(1, close) - const rest = trimmed.slice(close + 1) - return { host, port: rest.startsWith(":") ? rest.slice(1) : null } - } - const colon = trimmed.lastIndexOf(":") - if (colon !== -1 && /^\d+$/.test(trimmed.slice(colon + 1))) { - return { host: trimmed.slice(0, colon), port: trimmed.slice(colon + 1) } - } - return { host: trimmed, port: null } -} - -function hostMatches(patternHost: string, hostname: string): boolean { - if (patternHost === "*") return true - if (patternHost.startsWith("*.")) { - const suffix = patternHost.slice(1) // ".example.com" - return hostname.endsWith(suffix) && hostname.length > suffix.length - } - return patternHost === hostname -} - -/** First matching rule wins; hostnames compare case-insensitively. */ -export function matchHostRule( - rules: readonly HostRule[] | undefined, - parsed: URL -): HostRule | null { - if (!rules || rules.length === 0) return null - const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() - const port = effectivePort(parsed) - for (const rule of rules) { - const { host, port: rulePort } = splitPattern(rule.pattern) - if (!host) continue - if (!hostMatches(host, hostname)) continue - if (rulePort !== null && rulePort !== port) continue - return rule - } - return null -} - function invert(target: LinkTarget): LinkTarget { return target === "builtin" ? "system" : "builtin" } @@ -154,7 +108,9 @@ export function resolveLinkAction( const { surface } = ctx // 2. terminal rules - const rule = matchHostRule(ctx.hostRules, parsed) + const rule = + matchHostRule(ctx.managedHostRules, parsed) ?? + matchHostRule(ctx.hostRules, parsed) if (rule?.action === "block") { return { kind: "reject", reason: "blocked-host", url } } From ffd5ba46a1eb9f3a47ba65e8f0fc42385c87b6ae Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 14:50:43 +0800 Subject: [PATCH 24/79] fix(browser): close the gaps review found in site rules, load state and visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site rules: a host is matched without its trailing dot and with IPv6 literals in the canonical form the URL parser uses, so `example.com.` and `[0:0:0:0:0:0:0:1]` cannot slip past a rule for `example.com` / `[::1]`; brackets in a pattern must hold an IPv6 literal; the frontend lower-cases ASCII only, like the backend, so a Unicode fold cannot make a pattern one side accepts and the other drops. A blocked address typed while another page was still loading now stops that load and retires its watcher, or its commit or failure would have landed on top of the block page. Load state: a server redirect updates the address the tab is heading for (the error page names the redirect target); a load the policy interrupted — a refused redirect target, or a response that became a download — is settled on the page the tab shows instead of being reported by the watcher as an address that never arrived (measured: WebKit re-asks the navigation policy for a 302 target, so a redirect to a blocked host is refused; it then fails the load with WebKitErrorDomain 102, which is not a page failure). A popup id that is already taken is skipped rather than replacing the live surface behind it. Visibility: hide/show requests for a tab now apply one at a time under a per-tab lock in arrival order, so a show can no longer complete between a freeze capture's sequence check and the hide it guarded (measured with 40 interleaved requests: the final state is the last request's). Frontend: a click made in the first moments after launch, before the backend has said what it can do and which hosts the administrator blocks, is held until that answer is in instead of being routed on a guess (routed to the system browser it would have slipped past a managed block); the events bridge subscribes to preference changes before its first await, so a rule written by the settings window during the capabilities round trip reaches the backend, and a failed push is retried once; a stale show's answer no longer wipes the frame a newer hide painted; the frame is dropped when the error page takes the surface's place; the settings editor compares patterns in normalized form and keys rows by position. --- src-tauri/src/browser/hooks.rs | 61 ++++++++++-- src-tauri/src/browser/policy.rs | 60 ++++++++++-- src-tauri/src/browser/registry.rs | 24 ++++- src-tauri/src/browser/shim/macos.rs | 24 +++++ src-tauri/src/browser/surface_child.rs | 18 +++- src-tauri/src/commands/browser.rs | 28 ++++-- .../browser/browser-events-bridge.test.tsx | 63 +++++++++++++ .../browser/browser-events-bridge.tsx | 42 +++++++-- .../browser/browser-surface-host.test.tsx | 92 +++++++++++++++++++ .../browser/browser-surface-host.tsx | 18 +++- src/components/settings/browser-settings.tsx | 12 ++- src/hooks/use-open-url-target.test.tsx | 65 +++++++++++-- src/hooks/use-open-url-target.ts | 34 ++++++- src/lib/browser/host-rules.test.ts | 35 +++++++ src/lib/browser/host-rules.ts | 53 +++++++++-- 15 files changed, 570 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index df1d82d0fa..02eae07770 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -121,6 +121,24 @@ pub fn navigation_started(app: &AppHandle, tab_id: &str, url: &Url) { begin_load(app, tab_id); } +/// The server redirected the navigation in flight: the tab is heading for +/// `url` now, and that is the address an error page must name (and the one +/// a blank substitution stands in for). The engine asks the navigation +/// policy again for the new address, so a site rule still applies to it. +pub fn navigation_redirected(app: &AppHandle, tab_id: &str, url: &Url) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let state = registry.update(tab_id, |tab| { + tab.provisional_url = Some(url.to_string()); + tab.state.requested_url = url.to_string(); + tab.state.clone() + }); + if let Some(state) = state { + events::emit_state(app, &state); + } +} + /// A navigation the engine reported as failed (a platform delegate callback, /// where one exists — wry itself never reports failure). #[derive(Debug, Clone, PartialEq)] @@ -219,15 +237,42 @@ pub fn begin_load(app: &AppHandle, tab_id: &str) { } } -/// The tab's state once a navigation became a download: not loading, no -/// error, and back on the document it is showing (what it "asked for" is no -/// longer the file being fetched). -fn settle_after_download(state: &mut crate::browser::types::BrowserTabState) { +/// The tab's state once the navigation in flight ended without a page and +/// without a failure of its own — it became a download, or policy refused +/// where it was heading: not loading, no error, and back on the document it +/// is showing (what it "asked for" is no longer coming). +fn settle_without_page(state: &mut crate::browser::types::BrowserTabState) { state.loading = false; state.error = None; state.requested_url = state.url.clone(); } +/// The load in flight was ended by policy (WebKit's "frame load interrupted +/// by policy change"): the host refused the address a redirect led to, or +/// the response became a download. Either way no page is coming for it and +/// nothing is wrong with the tab: settle it on the document it shows and +/// retire the watcher, which would otherwise report the address that never +/// arrived as a failed load. Only acts while a provisional load is known to +/// be in flight — the same error also follows a refused NEW navigation, which +/// never started and needs nothing settled. +pub fn navigation_interrupted(app: &AppHandle, tab_id: &str) { + let Some(registry) = app.try_state::<BrowserRegistry>() else { + return; + }; + let state = registry + .update(tab_id, |tab| { + tab.provisional_url.take()?; + tab.load_seq += 1; + tab.download_seq = None; + settle_without_page(&mut tab.state); + Some(tab.state.clone()) + }) + .flatten(); + if let Some(state) = state { + events::emit_state(app, &state); + } +} + const LOAD_POLL: Duration = Duration::from_millis(500); const LOAD_FINISH_GRACE: Duration = Duration::from_millis(300); @@ -274,7 +319,7 @@ fn watch_load(app: AppHandle, tab_id: String, seq: u64) { // going to commit, so there is nothing to report. if tab.download_seq == Some(seq) { tab.download_seq = None; - settle_after_download(&mut tab.state); + settle_without_page(&mut tab.state); return tab.state.clone(); } let state = &mut tab.state; @@ -347,7 +392,7 @@ pub fn navigation_became_download(app: &AppHandle, tab_id: &str) { } let state = registry.update(tab_id, |tab| { tab.download_seq = None; - settle_after_download(&mut tab.state); + settle_without_page(&mut tab.state); tab.state.clone() }); if let Some(state) = state { @@ -401,7 +446,7 @@ mod tests { #[test] fn a_download_leaves_the_tab_on_the_page_it_is_showing() { let mut s = state("http://127.0.0.1:8790/", "http://127.0.0.1:8790/a.bin"); - settle_after_download(&mut s); + settle_without_page(&mut s); assert!(!s.loading); assert!(s.error.is_none()); assert_eq!(s.requested_url, "http://127.0.0.1:8790/"); @@ -414,7 +459,7 @@ mod tests { #[test] fn a_download_into_a_fresh_tab_settles_empty() { let mut s = state("", "http://127.0.0.1:8790/a.bin"); - settle_after_download(&mut s); + settle_without_page(&mut s); assert!(!s.loading); assert!(s.error.is_none()); assert_eq!(s.requested_url, ""); diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs index 6f8bc2028d..6bf25042a5 100644 --- a/src-tauri/src/browser/policy.rs +++ b/src-tauri/src/browser/policy.rs @@ -95,7 +95,7 @@ fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { if trimmed.is_empty() || trimmed.len() > MAX_PATTERN_LEN { return None; } - let (host, port) = if let Some(rest) = trimmed.strip_prefix('[') { + let (host, port, bracketed) = if let Some(rest) = trimmed.strip_prefix('[') { // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows. let close = rest.find(']')?; let host = &rest[..close]; @@ -105,14 +105,14 @@ fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { None if tail.is_empty() => None, None => return None, }; - (host.to_string(), port.map(str::to_string)) + (host.to_string(), port.map(str::to_string), true) } else { match trimmed.rsplit_once(':') { Some((host, digits)) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => { - (host.to_string(), Some(digits.to_string())) + (host.to_string(), Some(digits.to_string()), false) } Some(_) => return None, - None => (trimmed.clone(), None), + None => (trimmed.clone(), None, false), } }; let port = match port { @@ -126,7 +126,12 @@ fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { return None; } HostMatcher::Suffix(format!(".{suffix}")) - } else if valid_hostname(&host) || valid_ipv6(&host) { + } else if bracketed { + // Brackets mean an IPv6 literal and nothing else. Stored as typed, + // matched in the form URLs carry (`::1`, never `0:0:0:0:0:0:0:1`), + // or a rule would look right and never apply. + HostMatcher::Exact(canonical_ipv6(&host)?) + } else if valid_hostname(&host) { HostMatcher::Exact(host) } else { return None; @@ -134,6 +139,24 @@ fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { Some(ParsedPattern { host, port }) } +/// An IPv6 literal (without brackets) in the canonical form the URL parser +/// produces; `None` for anything that is not one. +fn canonical_ipv6(host: &str) -> Option<String> { + host.contains(':') + .then(|| host.parse::<std::net::Ipv6Addr>().ok()) + .flatten() + .map(|address| address.to_string()) +} + +/// The URL's host as a rule sees it: lower-case, without IPv6 brackets, and +/// without a trailing dot — `example.com.` names the same server as +/// `example.com`, and a block on one must hold for the other. +fn rule_hostname(url: &Url) -> Option<String> { + let host = url.host_str()?.trim_matches(|c| c == '[' || c == ']'); + let host = host.trim_end_matches('.'); + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + fn valid_hostname(host: &str) -> bool { !host.is_empty() && !host.starts_with('.') @@ -144,10 +167,6 @@ fn valid_hostname(host: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_') } -fn valid_ipv6(host: &str) -> bool { - host.contains(':') && host.bytes().all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.') -} - /// Whether `pattern` is one the table accepts (the settings UI validates /// with the same rule on its side). pub fn valid_pattern(pattern: &str) -> bool { @@ -184,7 +203,7 @@ impl ParsedPattern { /// among equally specific ones the first listed. Unparsable patterns never /// match. Same algorithm as `matchHostRule` on the frontend. pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostRule> { - let hostname = url.host_str()?.trim_matches(|c| c == '[' || c == ']').to_ascii_lowercase(); + let hostname = rule_hostname(url)?; let port = effective_port(url); let mut best: Option<(&HostRule, (u8, usize, u8))> = None; for rule in rules { @@ -493,6 +512,7 @@ mod tests { "*.corp.example:8443", "[::1]:3000", "[::1]", + "[0:0:0:0:0:0:0:1]", "127.0.0.1", "10.0.0.1:8080", "*:443", @@ -515,12 +535,32 @@ mod tests { "example..com", "[::1", "[::1]x", + "[1::2::3]", + "[not-an-address]", + "[fe80::1%25en0]", "*.*", ] { assert!(!valid_pattern(bad), "{bad:?} should not parse"); } } + /// The same server under a different spelling of its name must not slip + /// past a rule: a trailing dot, a long-form IPv6 literal, upper case. + #[test] + fn host_spellings_that_name_the_same_server_match() { + let block = [rule("example.com", HostRuleAction::Block)]; + assert!(match_host_rule(&block, &u("http://example.com./")).is_some()); + assert!(match_host_rule(&block, &u("http://EXAMPLE.COM/")).is_some()); + assert!(match_host_rule(&block, &u("http://user:pw@example.com:8080/")).is_some()); + let long = [rule("[0:0:0:0:0:0:0:1]:3000", HostRuleAction::Block)]; + assert!(match_host_rule(&long, &u("http://[::1]:3000/")).is_some()); + let short = [rule("[::1]", HostRuleAction::Block)]; + assert!(match_host_rule(&short, &u("http://[0:0:0:0:0:0:0:1]/")).is_some()); + // No host at all: nothing to match, not even `*`. + let any = [rule("*", HostRuleAction::Block)]; + assert!(match_host_rule(&any, &u("about:blank")).is_none()); + } + /// Mirror of the frontend's `matchHostRule` table. #[test] fn wildcard_semantics() { diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 75feebbe13..fca072c000 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -3,7 +3,7 @@ //! for map operations; every surface call happens on a clone taken out of it. use std::collections::{HashMap, VecDeque}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use serde_json::Value; @@ -84,6 +84,10 @@ impl BrowserTab { #[derive(Default)] pub struct BrowserRegistry { tabs: Mutex<HashMap<String, BrowserTab>>, + /// One async lock per tab for `set_visible`: a hide that first captures + /// a freeze frame spans an await, and the request behind it must not + /// apply in between (tokio's mutex hands the lock out in arrival order). + visibility: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>, } impl BrowserRegistry { @@ -91,6 +95,20 @@ impl BrowserRegistry { self.tabs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } + /// The visibility lock of a tab (created on first use, dropped with the + /// tab). The std mutex guarding the map is released before the caller + /// awaits on the returned lock. + pub fn visibility_lock(&self, tab_id: &str) -> Arc<tokio::sync::Mutex<()>> { + let mut locks = self + .visibility + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks + .entry(tab_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + pub fn insert(&self, tab: BrowserTab) -> Result<(), AppCommandError> { let mut tabs = self.lock(); let id = tab.state.tab_id.clone(); @@ -156,6 +174,10 @@ impl BrowserRegistry { } pub fn remove(&self, tab_id: &str) -> Option<BrowserTab> { + self.visibility + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(tab_id); self.lock().remove(tab_id) } diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 8f6aa4793b..1352ca1956 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -433,6 +433,13 @@ pub fn debug_view(webview: &wry::WebView) -> serde_json::Value { pub enum NavigationEvent { /// A main-frame navigation started; the URL it is heading for. Started(String), + /// The provisional navigation was redirected by the server; the URL it + /// is heading for now. + Redirected(String), + /// The provisional navigation was ended by policy — the host refused the + /// address it was redirected to, or the response became a download — so + /// no page is coming for it, and that is not a failure of the page. + Interrupted, Failed(LoadFailure), } @@ -520,6 +527,17 @@ define_class!( } } + #[unsafe(method(webView:didReceiveServerRedirectForProvisionalNavigation:))] + fn did_redirect_provisional(&self, webview: &WKWebView, _navigation: Option<&WKNavigation>) { + // SAFETY: main thread, live webview; `URL` is the redirect target + // by the time WebKit reports the redirect. + let url = unsafe { webview.URL().and_then(|u| u.absoluteString()) }.map(|s| s.to_string()); + tracing::debug!("[browser] navigation redirected: {url:?}"); + if let Some(url) = url { + (self.ivars().sink)(NavigationEvent::Redirected(url)); + } + } + #[unsafe(method(webView:didFailProvisionalNavigation:withError:))] fn did_fail_provisional(&self, _webview: &WKWebView, _navigation: Option<&WKNavigation>, error: &NSError) { self.report_failure(error, true); @@ -554,6 +572,12 @@ impl CodegNavigationDelegate { error.localizedDescription() ); let Some(kind) = kind else { + // WebKitErrorFrameLoadInterruptedByPolicyChange on the load in + // flight: our own navigation handler cancelled it (a refused + // redirect target) or it turned into a download. + if provisional && domain == "WebKitErrorDomain" && code == 102 { + (self.ivars().sink)(NavigationEvent::Interrupted); + } return; }; // SAFETY: main thread; the dictionary and its values are live. diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index c98d835579..892453afc7 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -137,6 +137,12 @@ fn navigation_sink(app: &AppHandle, tab_id: &str) -> shim::NavigationSink { hooks::navigation_started(&app, &tab_id, &url); } } + shim::NavigationEvent::Redirected(url) => { + if let Ok(url) = Url::parse(&url) { + hooks::navigation_redirected(&app, &tab_id, &url); + } + } + shim::NavigationEvent::Interrupted => hooks::navigation_interrupted(&app, &tab_id), shim::NavigationEvent::Failed(failure) => hooks::navigation_failed(&app, &tab_id, failure), }) } @@ -786,8 +792,16 @@ fn new_window_handler( #[cfg(target_os = "macos")] { - let seq = POPUP_SEQ.fetch_add(1, Ordering::SeqCst) + 1; - let tab_id = format!("{opener_tab_id}-p{seq}"); + // A fresh id: the counter is process-wide, but an id could still + // be taken (the frontend names its own tabs), and inserting over + // a live entry would drop that tab's webview from under it. + let tab_id = loop { + let seq = POPUP_SEQ.fetch_add(1, Ordering::SeqCst) + 1; + let candidate = format!("{opener_tab_id}-p{seq}"); + if !registry.contains(&candidate) && !SURFACES.with(|s| s.borrow().contains_key(&candidate)) { + break candidate; + } + }; let label = super::tab_label(&tab_id); let (bounds, devtools) = registry .update(&opener_tab_id, |tab| (tab.last_bounds, tab.devtools)) diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 9dd2da5df7..1d40bb82ed 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -450,16 +450,26 @@ pub async fn set_visible_core( freeze: bool, ) -> Result<Option<FrozenFrame>, AppCommandError> { let surface = surface_of(registry, tab_id)?; + // Arrival order first, then the tab's lock: requests apply one at a + // time and in the order they came, and one that a newer request has + // overtaken while it waited or captured is dropped rather than applied + // late (the newer one carries the state that stands). let seq = registry .update(tab_id, |tab| { tab.visible_seq += 1; tab.visible_seq }) .unwrap_or(0); + let lock = registry.visibility_lock(tab_id); + let _applying = lock.lock().await; + let superseded = || registry.update(tab_id, |tab| tab.visible_seq) != Some(seq); + if superseded() { + return Ok(None); + } let mut frame = None; if !visible && freeze && surface.is_embedded() { frame = capture_freeze_frame(&surface).await; - if registry.update(tab_id, |tab| tab.visible_seq) != Some(seq) { + if superseded() { return Ok(None); } } @@ -500,13 +510,19 @@ pub fn navigate_core( let url = parse_web_url(raw_url)?; let surface = surface_of(registry, tab_id)?; // Refused by a site rule: the block page takes the place of the page, - // as in a browser, and nothing is loaded. + // as in a browser, and nothing is loaded. Whatever was loading before + // is stopped and its watcher retired, or its commit or failure would + // land on top of the block a moment later. if blocked_by_policy(app, &url) { + let _ = surface.stop(); let state = registry - .update_state(tab_id, |state| { - state.requested_url = url.to_string(); - state.loading = false; - state.error = Some(blocked_error(&url)); + .update(tab_id, |tab| { + tab.load_seq += 1; + tab.provisional_url = None; + tab.state.requested_url = url.to_string(); + tab.state.loading = false; + tab.state.error = Some(blocked_error(&url)); + tab.state.clone() }) .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; events::emit_state(app, &state); diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 8e9c803ca5..79e872ec05 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -332,4 +332,67 @@ describe("BrowserEventsBridge", () => { }) view.unmount() }) + + // The settings window can write a rule while this document is still + // waiting for the backend's capabilities. The subscription that carries + // cross-window changes into this document's cache must already exist + // then, and the first push must carry that rule. + it("sends a rule written while the capabilities were still pending", async () => { + let resolveCapabilities: (caps: BrowserCapabilities) => void = () => {} + mocks.capabilities.mockImplementationOnce( + () => + new Promise<BrowserCapabilities>((resolve) => { + resolveCapabilities = resolve + }) + ) + render(<BrowserEventsBridge />) + await flush() + expect(mocks.browserSetHostRules).not.toHaveBeenCalled() + // Another window's write arrives as a storage event: only a subscriber + // installs the listener that drops this document's cached snapshot. + localStorage.setItem( + "browser:host-rules", + JSON.stringify([{ pattern: "blocked.example", action: "block" }]) + ) + window.dispatchEvent( + new StorageEvent("storage", { key: "browser:host-rules" }) + ) + await act(async () => { + resolveCapabilities({ + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live", reason: null }, + downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, + }) + await Promise.resolve() + }) + await flush() + expect(mocks.browserSetHostRules).toHaveBeenLastCalledWith([ + { pattern: "blocked.example", action: "block" }, + ]) + }) + + it("retries a failed push once", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }) + try { + mocks.browserSetHostRules.mockImplementationOnce(() => + Promise.reject(new Error("backend restarting")) + ) + render(<BrowserEventsBridge />) + await flush() + expect(mocks.browserSetHostRules).toHaveBeenCalledTimes(1) + await act(async () => { + vi.advanceTimersByTime(1000) + await Promise.resolve() + }) + expect(mocks.browserSetHostRules).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index b8565de289..68f4787a49 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -68,6 +68,9 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" * Only subscribes where a built-in browser exists; in web mode there is * nothing to hear. */ +/** Delay before the one retry of a failed site-rule push. */ +const HOST_RULES_RETRY_MS = 1000 + export function BrowserEventsBridge() { const { adoptBrowserTab, closeFileTab, openBrowserTab } = useWorkspaceActions() @@ -76,6 +79,34 @@ export function BrowserEventsBridge() { let cancelled = false const unsubscribers: Array<() => void> = [] + // The user's site rules go to the backend, which enforces `block` on + // every navigation. Subscribed BEFORE the first await: a change written + // by the settings window while the capabilities round trip is in flight + // must reach this document's cache (the subscription is what installs + // the cross-window listener) and then the backend. The first push + // happens once capabilities say a browser exists, and carries whatever + // is current then. A push that fails is retried once — the command has + // no reason to fail except the app shutting down, and a silent + // divergence would be an unenforced rule. + let ready = false + const push = (retry: boolean) => { + // Before the backend is known to exist a change only invalidates the + // cache (the subscription did that); the first push below picks up + // whatever is current by then. + if (!ready) return + void browserSetHostRules(getBrowserPrefs().hostRules).catch(() => { + if (cancelled) return + if (retry) { + window.setTimeout(() => { + if (!cancelled) push(false) + }, HOST_RULES_RETRY_MS) + } else { + console.warn("[browser] site rules could not be sent to the backend") + } + }) + } + unsubscribers.push(subscribeBrowserPrefs(() => push(true))) + void (async () => { const capabilities = await browserCapabilities() if (cancelled || !capabilities.available) return @@ -174,13 +205,10 @@ export function BrowserEventsBridge() { return } unsubscribers.push(...subs) - const pushHostRules = () => { - void browserSetHostRules(getBrowserPrefs().hostRules).catch(() => { - /* the backend keeps its last table */ - }) - } - pushHostRules() - unsubscribers.push(subscribeBrowserPrefs(pushHostRules)) + ready = true + // The first push, whether or not a change arrived meanwhile: the + // backend starts with an empty table. + push(true) })() return () => { diff --git a/src/components/browser/browser-surface-host.test.tsx b/src/components/browser/browser-surface-host.test.tsx index 27805aaa28..ebb1f6c822 100644 --- a/src/components/browser/browser-surface-host.test.tsx +++ b/src/components/browser/browser-surface-host.test.tsx @@ -249,6 +249,98 @@ describe("BrowserSurfaceHost", () => { expect(container.querySelector("img[data-browser-frozen-frame]")).toBeNull() }) + // Close and reopen an overlay at once: the show issued for the close is + // still in flight when the reopen's hide paints a new frame. The show's + // answer must not wipe that frame — the native view is hidden again. + it("keeps a newer hide's frame when a superseded show answers late", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host6"))) + let resolveShow: () => void = () => {} + let frames = 0 + api.browserSetVisible.mockImplementation( + (_id: string, visible: boolean, _handoff: boolean, freeze?: boolean) => { + if (visible) { + return new Promise<null>((resolve) => { + resolveShow = () => resolve(null) + }) + } + frames += 1 + return Promise.resolve( + freeze + ? { mime: "image/jpeg", data: `F${frames}`, width: 10, height: 10 } + : null + ) + } + ) + const { container } = render(<BrowserSurfaceHost tab={tab("host6")} />) + await flush() + const frameSrc = () => + container + .querySelector("img[data-browser-frozen-frame]") + ?.getAttribute("src") ?? null + + let release: () => void = () => {} + await act(async () => { + release = acquireNativeSurfaceOcclusion("dialog") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(frameSrc()).toBe("data:image/jpeg;base64,F1") + + // Close (show in flight, unresolved) and reopen right away. + await act(async () => { + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + const staleShow = resolveShow + await act(async () => { + release = acquireNativeSurfaceOcclusion("dialog") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(frameSrc()).toBe("data:image/jpeg;base64,F2") + + // The superseded show answers now: the newer frame stays. + await act(async () => { + staleShow() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(frameSrc()).toBe("data:image/jpeg;base64,F2") + + // The real close clears it. + await act(async () => { + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + await act(async () => { + resolveShow() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(frameSrc()).toBeNull() + }) + + it("drops the frame when the error page takes the surface's place", async () => { + api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host7"))) + api.browserSetVisible.mockImplementation( + (_id: string, visible: boolean, _handoff: boolean, freeze?: boolean) => + Promise.resolve( + !visible && freeze + ? { mime: "image/jpeg", data: "QUJD", width: 10, height: 10 } + : null + ) + ) + const { container, rerender } = render( + <BrowserSurfaceHost tab={tab("host7")} /> + ) + await flush() + await act(async () => { + acquireNativeSurfaceOcclusion("dialog") + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect( + container.querySelector("img[data-browser-frozen-frame]") + ).not.toBeNull() + rerender(<BrowserSurfaceHost tab={tab("host7")} hidden />) + expect(container.querySelector("img[data-browser-frozen-frame]")).toBeNull() + }) + it("does not ask for a frame when the error page hides the surface", async () => { api.browserOpenTab.mockImplementation(() => Promise.resolve(state("host5"))) render(<BrowserSurfaceHost tab={tab("host5")} hidden />) diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index e1a77351af..aa4e1655ba 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -93,6 +93,14 @@ export function BrowserSurfaceHost({ // Which hide a frame belongs to: one that lands after a later show or hide // is dropped rather than painted over the wrong state. const hideSeqRef = useRef(0) + // The error page replacing the surface also replaces any frame: a frame + // kept through an error would resurface, stale, when the error clears + // under a still-open overlay. Adjusted during render on the prop change. + const [wasHidden, setWasHidden] = useState(hidden) + if (wasHidden !== hidden) { + setWasHidden(hidden) + if (hidden) setFrozen(null) + } const occluded = useNativeSurfaceOccluded() const fallbackOverlay = useFallbackOverlayOpen() const view = useWorkspaceView() @@ -135,12 +143,18 @@ export function BrowserSurfaceHost({ if (lastVisibleRef.current !== visible) { lastVisibleRef.current = visible hideSeqRef.current += 1 + const seq = hideSeqRef.current if (visible) { + // The frame goes once the native view is back — and only if this + // show is still the latest request: an overlay closed and reopened + // at once has a newer hide in flight, whose frame this answer must + // not wipe from under it. void browserSetVisible(backendId, true, false) .catch(() => {}) - .finally(() => setFrozen(null)) + .finally(() => { + if (hideSeqRef.current === seq) setFrozen(null) + }) } else { - const seq = hideSeqRef.current const freeze = overlayHide && bounds.width > 0 && diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index 6133b8d524..0fab010971 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -158,7 +158,11 @@ function HostRulesEditor({ return } const pattern = normalizeHostRulePattern(draft) - if (rules.some((rule) => rule.pattern === pattern)) { + // Stored rows are normalized by this editor, but a row written by hand + // may not be; compare in the normalized form either way. + if ( + rules.some((rule) => normalizeHostRulePattern(rule.pattern) === pattern) + ) { setProblem("duplicate") return } @@ -173,6 +177,8 @@ function HostRulesEditor({ } const remove = (index: number) => { setBrowserHostRules(rules.filter((_, i) => i !== index)) + // A "duplicate" complaint may have been about this very row. + if (problem === "duplicate") setProblem(null) } return ( @@ -197,7 +203,9 @@ function HostRulesEditor({ ))} {rules.map((rule, index) => ( <div - key={rule.pattern} + // Position plus pattern: two rows can carry the same pattern when + // the table was written by hand, and the key must still be unique. + key={`${index}:${rule.pattern}`} className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background px-3 py-2" > <span className="min-w-0 truncate font-mono text-xs"> diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx index e6818c1aaf..b4235a5bb7 100644 --- a/src/hooks/use-open-url-target.test.tsx +++ b/src/hooks/use-open-url-target.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react" +import { act, renderHook } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ @@ -6,11 +6,13 @@ const mocks = vi.hoisted(() => ({ viewerOpen: vi.fn(), openInSystemBrowser: vi.fn(() => Promise.resolve()), openWithOsHandler: vi.fn(() => Promise.resolve()), - toast: vi.fn(), + toast: Object.assign(vi.fn(), { error: vi.fn() }), remote: false, route: { isConversations: true } as { isConversations: boolean } | null, viewerHost: null as { open: (r: unknown) => void } | null, actions: null as { openBrowserTab: (url: string) => string | null } | null, + /** The backend's answer to `browser_capabilities` for the deferral tests. */ + transportCall: vi.fn(() => Promise.resolve(undefined as unknown)), })) vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })) @@ -31,10 +33,13 @@ vi.mock("@/lib/link-open", () => ({ vi.mock("@/lib/transport", () => ({ isRemoteDesktopMode: () => mocks.remote, isDesktop: () => true, - getTransport: () => ({ call: vi.fn() }), + getTransport: () => ({ call: mocks.transportCall }), })) -import { setBrowserCapabilitiesForTests } from "@/lib/browser/browser-api" +import { + resetBrowserCapabilitiesCacheForTests, + setBrowserCapabilitiesForTests, +} from "@/lib/browser/browser-api" import { resetBrowserPrefsForTests } from "@/lib/browser/browser-prefs" import { isPrimaryModifier, useOpenUrlTarget } from "./use-open-url-target" @@ -53,7 +58,11 @@ const AVAILABLE = { describe("useOpenUrlTarget", () => { beforeEach(() => { resetBrowserPrefsForTests() + resetBrowserCapabilitiesCacheForTests() setBrowserCapabilitiesForTests(AVAILABLE) + mocks.transportCall.mockReset() + mocks.transportCall.mockImplementation(() => Promise.resolve(AVAILABLE)) + mocks.toast.error.mockClear() mocks.openBrowserTab.mockClear() mocks.viewerOpen.mockClear() mocks.openInSystemBrowser.mockClear() @@ -98,16 +107,52 @@ describe("useOpenUrlTarget", () => { }) it("falls back to the system browser when no built-in browser exists", () => { - setBrowserCapabilitiesForTests(null) // not resolved yet - const { result } = renderHook(() => useOpenUrlTarget()) - expect( - result.current("https://example.com/", { source: "transcript" }).kind - ).toBe("system") setBrowserCapabilitiesForTests({ ...AVAILABLE, available: false }) + const { result } = renderHook(() => useOpenUrlTarget()) expect( result.current("https://example.com/", { source: "toolCard" }).kind ).toBe("system") - expect(mocks.openInSystemBrowser).toHaveBeenCalledTimes(2) + expect(mocks.openInSystemBrowser).toHaveBeenCalledTimes(1) + }) + + // The first moments after launch: the backend has not yet said what it can + // do or which hosts the administrator blocks. A click then waits for the + // answer instead of being routed on a guess — routed to the system browser + // it would slip past a managed block. + it("holds a click until the capabilities are known, then routes it — honouring a managed block", async () => { + setBrowserCapabilitiesForTests(null) + mocks.transportCall.mockImplementation(() => + Promise.resolve({ + ...AVAILABLE, + policy: { + enabled: true, + managedRules: [{ pattern: "blocked.example", action: "block" }], + managedSource: "/etc/codeg/policy.json", + }, + }) + ) + const { result } = renderHook(() => useOpenUrlTarget()) + const outcome = result.current("https://blocked.example/", { + source: "transcript", + }) + expect(outcome.kind).toBe("deferred") + expect(mocks.openInSystemBrowser).not.toHaveBeenCalled() + expect(mocks.openBrowserTab).not.toHaveBeenCalled() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + // Decided with the managed rules in hand: blocked, and said so. + expect(mocks.openInSystemBrowser).not.toHaveBeenCalled() + expect(mocks.openBrowserTab).not.toHaveBeenCalled() + expect(mocks.toast.error).toHaveBeenCalledWith("blockedHost") + + // An ordinary link, once the answer is in, goes where it always goes. + const later = result.current("https://example.com/", { + source: "transcript", + }) + expect(later.kind).toBe("builtin") + expect(mocks.openBrowserTab).toHaveBeenCalledWith("https://example.com/") }) it("uses the viewer drawer under a full-page route", () => { diff --git a/src/hooks/use-open-url-target.ts b/src/hooks/use-open-url-target.ts index 7c91268f6c..c15612063f 100644 --- a/src/hooks/use-open-url-target.ts +++ b/src/hooks/use-open-url-target.ts @@ -7,7 +7,10 @@ import { toast } from "sonner" import { useSessionViewerHost } from "@/components/message/session-viewer-host-context" import { useOptionalWorkbenchRoute } from "@/contexts/workbench-route-context" import { useOptionalWorkspaceActions } from "@/contexts/workspace-context" -import { browserCapabilitiesSnapshot } from "@/lib/browser/browser-api" +import { + browserCapabilities, + browserCapabilitiesSnapshot, +} from "@/lib/browser/browser-api" import { getBrowserPrefs, markBrowserFirstOpenSeen, @@ -22,7 +25,11 @@ import { type LinkAction, type LinkSurface, } from "@/lib/resolve-link-action" -import { isRemoteDesktopMode } from "@/lib/transport" +import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" + +/** What a click did — or, on the desktop before the backend has answered + * what it can do, that it will do it as soon as the answer is in. */ +export type OpenUrlOutcome = LinkAction | { kind: "deferred" } export interface OpenUrlOptions { source: LinkSource @@ -64,7 +71,7 @@ export function useOpenUrlTarget() { const viewerHost = useSessionViewerHost() const fileColumnVisible = route ? route.isConversations : true - return useCallback( + const run = useCallback( (url: string, options: OpenUrlOptions): LinkAction => { const capabilities = browserCapabilitiesSnapshot() const surface: LinkSurface = { @@ -130,4 +137,25 @@ export function useOpenUrlTarget() { }, [fileColumnVisible, openBrowserTab, t, viewerHost] ) + + return useCallback( + (url: string, options: OpenUrlOptions): OpenUrlOutcome => { + // On the desktop the decision needs the backend's answer — whether a + // built-in browser exists and which site rules the administrator has + // fixed. Before it is in (the first moments after launch) a click is + // held until it arrives rather than routed on a guess: routed to the + // system browser it would slip past a managed block. Only the desktop + // waits; there the system browser is reached through a command, not + // through `window.open`, so no user gesture is spent. In the browser + // the answer is immediate and this never runs. + if (browserCapabilitiesSnapshot() === null && isDesktop()) { + void browserCapabilities().then(() => { + run(url, options) + }) + return { kind: "deferred" } + } + return run(url, options) + }, + [run] + ) } diff --git a/src/lib/browser/host-rules.test.ts b/src/lib/browser/host-rules.test.ts index 49e625a1bc..c5db77ff9c 100644 --- a/src/lib/browser/host-rules.test.ts +++ b/src/lib/browser/host-rules.test.ts @@ -23,6 +23,7 @@ describe("host rule patterns", () => { "*.corp.example:8443", "[::1]:3000", "[::1]", + "[0:0:0:0:0:0:0:1]", "127.0.0.1", "10.0.0.1:8080", "*:443", @@ -31,6 +32,13 @@ describe("host rule patterns", () => { } }) + it("lower-cases ASCII only, like the Rust side", () => { + // U+212A KELVIN SIGN folds to `k` under Unicode lower-casing; a pattern + // is ASCII, so it is not a pattern on either side. + expect(validateHostRulePattern("\u212A.example")).toBe("invalid") + expect(normalizeHostRulePattern("\u212A.example")).toBe("\u212A.example") + }) + it("refuses URLs, paths, bad ports and malformed hosts", () => { expect(validateHostRulePattern("")).toBe("empty") expect(validateHostRulePattern(" ")).toBe("empty") @@ -48,6 +56,8 @@ describe("host rule patterns", () => { "example..com", "[::1", "[::1]x", + "[1::2::3]", + "[not-an-address]", "*.*", ]) { expect(validateHostRulePattern(bad), bad).toBe("invalid") @@ -131,6 +141,31 @@ describe("matchHostRule", () => { expect(matchHostRule(undefined, new URL("https://example.com/"))).toBeNull() }) + // Mirror of the Rust `host_spellings_that_name_the_same_server_match`. + it("matches every spelling of the same server, and nothing without a host", () => { + const block: HostRule[] = [{ pattern: "example.com", action: "block" }] + expect(matchHostRule(block, new URL("http://example.com./"))).not.toBeNull() + expect(matchHostRule(block, new URL("http://EXAMPLE.COM/"))).not.toBeNull() + expect( + matchHostRule(block, new URL("http://user:pw@example.com:8080/")) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "[0:0:0:0:0:0:0:1]:3000", action: "block" }], + new URL("http://[::1]:3000/") + ) + ).not.toBeNull() + expect( + matchHostRule( + [{ pattern: "[::1]", action: "block" }], + new URL("http://[0:0:0:0:0:0:0:1]/") + ) + ).not.toBeNull() + expect( + matchHostRule([{ pattern: "*", action: "block" }], new URL("about:blank")) + ).toBeNull() + }) + it("picks the most specific rule regardless of order, first listed on a tie", () => { const rules: HostRule[] = [ { pattern: "*", action: "system" }, diff --git a/src/lib/browser/host-rules.ts b/src/lib/browser/host-rules.ts index df63cda939..77c318747b 100644 --- a/src/lib/browser/host-rules.ts +++ b/src/lib/browser/host-rules.ts @@ -43,8 +43,24 @@ function validHostname(host: string): boolean { ) } -function validIpv6(host: string): boolean { - return host.includes(":") && /^[0-9a-f:.]+$/.test(host) +/** + * An IPv6 literal (without brackets) in the canonical form the URL parser + * produces (`::1`, never `0:0:0:0:0:0:0:1`), or `null` when it is not one. + * The URL parser is the one canonicalizer both sides agree with. + */ +function canonicalIpv6(host: string): string | null { + if (!host.includes(":") || !/^[0-9a-f:.]+$/.test(host)) return null + try { + return new URL(`http://[${host}]/`).hostname.replace(/^\[|\]$/g, "") + } catch { + return null + } +} + +/** ASCII-only lower-casing, like the Rust side: a non-ASCII letter is not + * part of a pattern, and Unicode folding (`K` → `k`) must not make one. */ +function asciiLower(text: string): string { + return text.replace(/[A-Z]/g, (c) => c.toLowerCase()) } /** @@ -54,11 +70,12 @@ function validIpv6(host: string): boolean { export function parseHostRulePattern( pattern: string ): ParsedHostRulePattern | null { - const trimmed = pattern.trim().toLowerCase() + const trimmed = asciiLower(pattern.trim()) if (!trimmed || trimmed.length > MAX_PATTERN_LEN) return null let host: string let portText: string | null = null - if (trimmed.startsWith("[")) { + const bracketed = trimmed.startsWith("[") + if (bracketed) { // `[::1]:3000` — an IPv6 literal keeps its brackets; the port follows. const close = trimmed.indexOf("]") if (close === -1) return null @@ -89,7 +106,12 @@ export function parseHostRulePattern( const suffix = host.slice(2) if (!validHostname(suffix)) return null matcher = { kind: "suffix", suffix: `.${suffix}` } - } else if (validHostname(host) || validIpv6(host)) { + } else if (bracketed) { + // Brackets mean an IPv6 literal and nothing else. + const canonical = canonicalIpv6(host) + if (!canonical) return null + matcher = { kind: "exact", host: canonical } + } else if (validHostname(host)) { matcher = { kind: "exact", host } } else { return null @@ -105,9 +127,23 @@ export function validateHostRulePattern( return parseHostRulePattern(pattern) ? null : "invalid" } -/** The form a pattern is stored in: trimmed and lower-cased. */ +/** The form a pattern is stored in: trimmed and (ASCII) lower-cased. */ export function normalizeHostRulePattern(pattern: string): string { - return pattern.trim().toLowerCase() + return asciiLower(pattern.trim()) +} + +/** + * The URL's host as a rule sees it: lower-case, without IPv6 brackets and + * without a trailing dot — `example.com.` names the same server as + * `example.com`, and a block on one must hold for the other. Same as the + * Rust side's `rule_hostname`. + */ +export function ruleHostname(parsed: URL): string | null { + const host = parsed.hostname + .replace(/^\[|\]$/g, "") + .replace(/\.+$/, "") + .toLowerCase() + return host.length > 0 ? host : null } function effectivePort(parsed: URL): number | null { @@ -167,7 +203,8 @@ export function matchHostRule( parsed: URL ): HostRule | null { if (!rules || rules.length === 0) return null - const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() + const hostname = ruleHostname(parsed) + if (!hostname) return null const port = effectivePort(parsed) let best: { rule: HostRule; score: [number, number, number] } | null = null for (const rule of rules) { From 198ccd42b9d95b4770f124325a347dec67f1713a Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 14:58:58 +0800 Subject: [PATCH 25/79] fix(browser): hold only web clicks for the capabilities answer The cold-start deferral held every link until the backend had answered, including mailto:, tel: and file paths, which do not depend on the answer at all. Only an http(s) click waits now. The link-safety tests that click on the desktop get an answered backend (no built-in browser), which is the state the app is in whenever a user can click. --- .../ai-elements/link-safety.test.tsx | 20 +++++++++++++++ src/hooks/use-open-url-target.ts | 25 ++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index 1d01af3541..07f2aaea31 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -7,6 +7,22 @@ import { openLinkWithSafety, useStreamdownLinkSafety, } from "@/components/ai-elements/link-safety" +import { setBrowserCapabilitiesForTests } from "@/lib/browser/browser-api" + +/** A desktop whose backend has answered: no built-in browser here. Set in + * the desktop cases — until the backend has answered, a web click on the + * desktop is held back rather than routed (`useOpenUrlTarget`). */ +const DESKTOP_WITHOUT_BROWSER = { + available: false, + surface: null, + platform: "macos", + channel: "degraded" as const, + reasons: ["child surface not compiled"], + isolatedStorage: false, + proxy: { url: null, applies: "unsupported" as const, reason: null }, + downloadsDir: "", + policy: { enabled: true, managedRules: [], managedSource: null }, +} const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), @@ -90,6 +106,7 @@ describe("link safety direct opening", () => { mocks.toastError.mockReset() mocks.isDesktop.mockReset() mocks.isDesktop.mockReturnValue(false) + setBrowserCapabilitiesForTests(null) mocks.getActiveRemoteConnectionId.mockReset() mocks.getActiveRemoteConnectionId.mockReturnValue(null) mocks.openFilePreview.mockResolvedValue(undefined) @@ -269,6 +286,7 @@ describe("link safety direct opening", () => { // canonicalize. mocks.isDesktop.mockReturnValue(true) mocks.openUrl.mockResolvedValue(undefined) + setBrowserCapabilitiesForTests(DESKTOP_WITHOUT_BROWSER) render(<LinkSafetyHarness url="//cdn.example.com/app.js" />) @@ -287,6 +305,7 @@ describe("link safety direct opening", () => { it("routes desktop external links through the platform opener instead of streamdown", async () => { mocks.isDesktop.mockReturnValue(true) mocks.openUrl.mockResolvedValue(undefined) + setBrowserCapabilitiesForTests(DESKTOP_WITHOUT_BROWSER) render(<LinkSafetyHarness url="https://example.com/docs" />) @@ -306,6 +325,7 @@ describe("link safety direct opening", () => { mocks.isDesktop.mockReturnValue(true) mocks.getActiveRemoteConnectionId.mockReturnValue("conn-7") mocks.openUrl.mockResolvedValue(undefined) + setBrowserCapabilitiesForTests(DESKTOP_WITHOUT_BROWSER) render(<LinkSafetyHarness url="https://example.com/docs" />) diff --git a/src/hooks/use-open-url-target.ts b/src/hooks/use-open-url-target.ts index c15612063f..75833ff9bc 100644 --- a/src/hooks/use-open-url-target.ts +++ b/src/hooks/use-open-url-target.ts @@ -20,6 +20,7 @@ import { } from "@/lib/browser/browser-prefs" import { displayHostPort } from "@/lib/browser/browser-url" import { openInSystemBrowser, openWithOsHandler } from "@/lib/link-open" +import { classifyLinkTarget } from "@/lib/link-classify" import { resolveLinkAction, type LinkAction, @@ -140,15 +141,21 @@ export function useOpenUrlTarget() { return useCallback( (url: string, options: OpenUrlOptions): OpenUrlOutcome => { - // On the desktop the decision needs the backend's answer — whether a - // built-in browser exists and which site rules the administrator has - // fixed. Before it is in (the first moments after launch) a click is - // held until it arrives rather than routed on a guess: routed to the - // system browser it would slip past a managed block. Only the desktop - // waits; there the system browser is reached through a command, not - // through `window.open`, so no user gesture is spent. In the browser - // the answer is immediate and this never runs. - if (browserCapabilitiesSnapshot() === null && isDesktop()) { + // On the desktop the decision for a WEB address needs the backend's + // answer — whether a built-in browser exists and which site rules the + // administrator has fixed. Before it is in (the first moments after + // launch) such a click is held until it arrives rather than routed on + // a guess: routed to the system browser it would slip past a managed + // block. Only the desktop waits; there the system browser is reached + // through a command, not through `window.open`, so no user gesture is + // spent. In the browser the answer is immediate and this never runs. + // `mailto:`, `tel:`, file paths and refused schemes do not depend on + // the answer and go through at once. + if ( + browserCapabilitiesSnapshot() === null && + isDesktop() && + classifyLinkTarget(url).kind === "http" + ) { void browserCapabilities().then(() => { run(url, options) }) From 3d2c8098e4def6e9ba68afec501c476bdb118c8e Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 15:15:01 +0800 Subject: [PATCH 26/79] fix(browser): tie-break rules by restrictiveness, key rules by what they match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spellings of one host (`[::1]` and `[0:0:0:0:0:0:0:1]`) can both sit in the table; among equally specific rules the more restrictive action now wins on both sides, and the settings editor recognises such duplicates by what a pattern matches rather than by its text. A bracketed pattern must be an IPv6 literal on both sides (`[*]` is not a wildcard). The delegate wrapper now tells navigations apart by the WKNavigation object WebKit hands to every callback of one navigation: a redirect, interruption or failure reported for a superseded navigation — a download the previous document started, an earlier load a newer one replaced — no longer settles or fails the load in flight. A visibility request also remembers which incarnation of a tab id it was made for, so a hide still capturing its frame when the tab is closed and reopened under the same id cannot apply to the new tab; a destroyed window's tabs drop their visibility locks with them. --- src-tauri/src/browser/policy.rs | 63 +++++++++++++---- src-tauri/src/browser/registry.rs | 35 +++++++--- src-tauri/src/browser/shim/macos.rs | 44 ++++++++++-- src-tauri/src/commands/browser.rs | 12 ++-- .../settings/browser-settings.test.tsx | 8 +++ src/components/settings/browser-settings.tsx | 16 +++-- src/lib/browser/host-rules.test.ts | 39 ++++++++++- src/lib/browser/host-rules.ts | 68 +++++++++++++------ 8 files changed, 225 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs index 6bf25042a5..e111a78d27 100644 --- a/src-tauri/src/browser/policy.rs +++ b/src-tauri/src/browser/policy.rs @@ -119,18 +119,19 @@ fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { Some(digits) => Some(digits.parse::<u16>().ok().filter(|p| *p > 0)?), None => None, }; - let host = if host == "*" { + let host = if bracketed { + // Brackets mean an IPv6 literal and nothing else — not a wildcard, + // not a name. Stored as typed, matched in the form URLs carry + // (`::1`, never `0:0:0:0:0:0:0:1`), or a rule would look right and + // never apply. + HostMatcher::Exact(canonical_ipv6(&host)?) + } else if host == "*" { HostMatcher::Any } else if let Some(suffix) = host.strip_prefix("*.") { if !valid_hostname(suffix) { return None; } HostMatcher::Suffix(format!(".{suffix}")) - } else if bracketed { - // Brackets mean an IPv6 literal and nothing else. Stored as typed, - // matched in the form URLs carry (`::1`, never `0:0:0:0:0:0:0:1`), - // or a rule would look right and never apply. - HostMatcher::Exact(canonical_ipv6(&host)?) } else if valid_hostname(&host) { HostMatcher::Exact(host) } else { @@ -199,13 +200,27 @@ impl ParsedPattern { } } -/// The rule that applies to `url`: the most specific matching pattern, and -/// among equally specific ones the first listed. Unparsable patterns never -/// match. Same algorithm as `matchHostRule` on the frontend. +impl HostRuleAction { + /// Among equally specific rules the more restrictive one wins — two + /// spellings of one host (`[::1]` and `[0:0:0:0:0:0:0:1]`) may both be + /// in the table, and a block must not depend on which was listed first. + fn restrictiveness(self) -> u8 { + match self { + HostRuleAction::Block => 2, + HostRuleAction::System => 1, + HostRuleAction::Builtin => 0, + } + } +} + +/// The rule that applies to `url`: the most specific matching pattern; among +/// equally specific ones the most restrictive action, and among those the +/// first listed. Unparsable patterns never match. Same algorithm as +/// `matchHostRule` on the frontend. pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostRule> { let hostname = rule_hostname(url)?; let port = effective_port(url); - let mut best: Option<(&HostRule, (u8, usize, u8))> = None; + let mut best: Option<(&HostRule, (u8, usize, u8, u8))> = None; for rule in rules { let Some(parsed) = parse_pattern(&rule.pattern) else { continue; @@ -213,7 +228,8 @@ pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostR if !parsed.matches(&hostname, port) { continue; } - let score = parsed.specificity(); + let (kind, len, pinned) = parsed.specificity(); + let score = (kind, len, pinned, rule.action.restrictiveness()); if best.as_ref().is_none_or(|(_, current)| score > *current) { best = Some((rule, score)); } @@ -538,6 +554,9 @@ mod tests { "[1::2::3]", "[not-an-address]", "[fe80::1%25en0]", + "[*]", + "[*.example.com]", + "[*]:443", "*.*", ] { assert!(!valid_pattern(bad), "{bad:?} should not parse"); @@ -605,15 +624,33 @@ mod tests { assert_eq!(action("https://wiki.corp.example/"), Some(HostRuleAction::Builtin)); assert_eq!(action("https://a.sso.corp.example/"), Some(HostRuleAction::Builtin)); assert_eq!(action("https://elsewhere.example/"), Some(HostRuleAction::System)); - // Equal specificity: the first listed. + // Equal specificity: the more restrictive action, whatever the order + // — including two spellings of one host. let tie = [ rule("dup.example", HostRuleAction::Builtin), rule("dup.example", HostRuleAction::Block), ]; assert_eq!( match_host_rule(&tie, &u("https://dup.example/")).map(|r| r.action), - Some(HostRuleAction::Builtin) + Some(HostRuleAction::Block) ); + let aliases = [ + rule("[0:0:0:0:0:0:0:1]", HostRuleAction::System), + rule("[::1]", HostRuleAction::Block), + ]; + assert_eq!( + match_host_rule(&aliases, &u("http://[::1]/")).map(|r| r.action), + Some(HostRuleAction::Block) + ); + // Equal in every respect: the first listed. + let same = [ + rule("dup.example", HostRuleAction::System), + rule("dup.example", HostRuleAction::System), + ]; + assert!(std::ptr::eq( + match_host_rule(&same, &u("https://dup.example/")).unwrap(), + &same[0] + )); } #[test] diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index fca072c000..ae703f7ede 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -3,6 +3,7 @@ //! for map operations; every surface call happens on a clone taken out of it. use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; @@ -50,6 +51,10 @@ pub struct BrowserTab { /// newer request has been made meanwhile — otherwise a quick close of the /// overlay would be followed by a stale hide. pub visible_seq: u64, + /// Which incarnation of this tab id this is (a tab id is reused when a + /// released tab is brought back). An operation that spans an await + /// captures it and stands down if the id now names a later incarnation. + pub generation: u64, /// URL of the main-frame navigation the engine reported as started and /// has neither committed nor failed yet (platforms with a navigation /// delegate only). Lets a commit of `about:blank` in its place be @@ -75,6 +80,7 @@ impl BrowserTab { load_seq: 0, download_seq: None, visible_seq: 0, + generation: 0, provisional_url: None, gestures: VecDeque::with_capacity(GESTURE_RING_CAPACITY), } @@ -88,6 +94,8 @@ pub struct BrowserRegistry { /// a freeze frame spans an await, and the request behind it must not /// apply in between (tokio's mutex hands the lock out in arrival order). visibility: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>, + /// Source of `BrowserTab::generation`, never reused within a process. + generations: AtomicU64, } impl BrowserRegistry { @@ -109,7 +117,8 @@ impl BrowserRegistry { .clone() } - pub fn insert(&self, tab: BrowserTab) -> Result<(), AppCommandError> { + pub fn insert(&self, mut tab: BrowserTab) -> Result<(), AppCommandError> { + tab.generation = self.generations.fetch_add(1, Ordering::Relaxed) + 1; let mut tabs = self.lock(); let id = tab.state.tab_id.clone(); if tabs.contains_key(&id) { @@ -184,13 +193,23 @@ impl BrowserRegistry { /// Detach every tab owned by a window (called when that window is /// destroyed); the caller closes the returned surfaces. pub fn remove_by_owner(&self, owner_window: &str) -> Vec<BrowserTab> { - let mut tabs = self.lock(); - let ids: Vec<String> = tabs - .values() - .filter(|t| t.state.owner_window == owner_window) - .map(|t| t.state.tab_id.clone()) - .collect(); - ids.into_iter().filter_map(|id| tabs.remove(&id)).collect() + let removed: Vec<BrowserTab> = { + let mut tabs = self.lock(); + let ids: Vec<String> = tabs + .values() + .filter(|t| t.state.owner_window == owner_window) + .map(|t| t.state.tab_id.clone()) + .collect(); + ids.into_iter().filter_map(|id| tabs.remove(&id)).collect() + }; + let mut locks = self + .visibility + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for tab in &removed { + locks.remove(&tab.state.tab_id); + } + removed } pub fn push_gesture(&self, tab_id: &str, payload: Value) { diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index 1352ca1956..bee2bf3e03 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -467,6 +467,15 @@ pub fn current_navigation_is_main_frame() -> Option<bool> { pub struct NavigationDelegateIvars { inner: Retained<ProtocolObject<dyn WKNavigationDelegate>>, sink: NavigationSink, + /// The `WKNavigation` that started most recently (by identity). A + /// redirect, interruption or failure reported for an OLDER navigation + /// — a superseded load, a download the previous document started — is + /// not news about the load in flight and is dropped. + current: Cell<usize>, +} + +fn navigation_id(navigation: Option<&WKNavigation>) -> usize { + navigation.map_or(0, |n| n as *const WKNavigation as usize) } define_class!( @@ -516,7 +525,8 @@ define_class!( } #[unsafe(method(webView:didStartProvisionalNavigation:))] - fn did_start_provisional(&self, webview: &WKWebView, _navigation: Option<&WKNavigation>) { + fn did_start_provisional(&self, webview: &WKWebView, navigation: Option<&WKNavigation>) { + self.ivars().current.set(navigation_id(navigation)); // `URL` is the active URL: the provisional one while a load is in // flight, so this is where the navigation is heading. // SAFETY: main thread, live webview. @@ -528,7 +538,10 @@ define_class!( } #[unsafe(method(webView:didReceiveServerRedirectForProvisionalNavigation:))] - fn did_redirect_provisional(&self, webview: &WKWebView, _navigation: Option<&WKNavigation>) { + fn did_redirect_provisional(&self, webview: &WKWebView, navigation: Option<&WKNavigation>) { + if self.is_stale(navigation) { + return; + } // SAFETY: main thread, live webview; `URL` is the redirect target // by the time WebKit reports the redirect. let url = unsafe { webview.URL().and_then(|u| u.absoluteString()) }.map(|s| s.to_string()); @@ -539,12 +552,19 @@ define_class!( } #[unsafe(method(webView:didFailProvisionalNavigation:withError:))] - fn did_fail_provisional(&self, _webview: &WKWebView, _navigation: Option<&WKNavigation>, error: &NSError) { + fn did_fail_provisional(&self, _webview: &WKWebView, navigation: Option<&WKNavigation>, error: &NSError) { + if self.is_stale(navigation) { + tracing::debug!("[browser] ignoring the failure of a superseded navigation"); + return; + } self.report_failure(error, true); } #[unsafe(method(webView:didFailNavigation:withError:))] - fn did_fail(&self, _webview: &WKWebView, _navigation: Option<&WKNavigation>, error: &NSError) { + fn did_fail(&self, _webview: &WKWebView, navigation: Option<&WKNavigation>, error: &NSError) { + if self.is_stale(navigation) { + return; + } self.report_failure(error, false); } } @@ -556,13 +576,23 @@ impl CodegNavigationDelegate { sink: NavigationSink, mtm: MainThreadMarker, ) -> Retained<Self> { - let this = mtm - .alloc::<Self>() - .set_ivars(NavigationDelegateIvars { inner, sink }); + let this = mtm.alloc::<Self>().set_ivars(NavigationDelegateIvars { + inner, + sink, + current: Cell::new(0), + }); // SAFETY: plain NSObject init. unsafe { msg_send![super(this), init] } } + /// A callback about a navigation other than the one that started last. + /// WebKit hands the same `WKNavigation` object to every callback of one + /// navigation, so identity is the comparison; a callback without one + /// (nil, as for some engine-internal loads) is taken at face value. + fn is_stale(&self, navigation: Option<&WKNavigation>) -> bool { + navigation.is_some_and(|n| n as *const WKNavigation as usize != self.ivars().current.get()) + } + fn report_failure(&self, error: &NSError, provisional: bool) { let domain = error.domain().to_string(); let code = i64::try_from(error.code()).unwrap_or(i64::MAX); diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 1d40bb82ed..dc90c20c9c 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -454,15 +454,19 @@ pub async fn set_visible_core( // time and in the order they came, and one that a newer request has // overtaken while it waited or captured is dropped rather than applied // late (the newer one carries the state that stands). - let seq = registry + let stamp = registry .update(tab_id, |tab| { tab.visible_seq += 1; - tab.visible_seq + (tab.visible_seq, tab.generation) }) - .unwrap_or(0); + .unwrap_or_default(); let lock = registry.visibility_lock(tab_id); let _applying = lock.lock().await; - let superseded = || registry.update(tab_id, |tab| tab.visible_seq) != Some(seq); + // Both the sequence and the incarnation: the id may have been closed and + // reopened while this waited, and the new tab's own counter must not be + // mistaken for ours. + let superseded = + || registry.update(tab_id, |tab| (tab.visible_seq, tab.generation)) != Some(stamp); if superseded() { return Ok(None); } diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index 5be5f8ba07..2017baf452 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -113,6 +113,14 @@ describe("BrowserSettingsSection", () => { ).toBeInTheDocument() expect(getBrowserPrefs().hostRules).toHaveLength(1) + // Another spelling of the same rule is the same rule. + fireEvent.change(pattern, { target: { value: "BLOCKED.example" } }) + fireEvent.click(screen.getByRole("button", { name: "Add" })) + expect( + screen.getByText("There is already a rule for this pattern") + ).toBeInTheDocument() + expect(getBrowserPrefs().hostRules).toHaveLength(1) + fireEvent.change(pattern, { target: { value: "https://not a pattern" } }) fireEvent.click(screen.getByRole("button", { name: "Add" })) expect(screen.getByText("Not a valid pattern")).toBeInTheDocument() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index 0fab010971..cb6bbf49ce 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -72,6 +72,7 @@ import { } from "@/lib/browser/browser-prefs" import { HOST_RULE_ACTIONS, + hostRulePatternKey, normalizeHostRulePattern, validateHostRulePattern, type HostRule, @@ -158,11 +159,12 @@ function HostRulesEditor({ return } const pattern = normalizeHostRulePattern(draft) - // Stored rows are normalized by this editor, but a row written by hand - // may not be; compare in the normalized form either way. - if ( - rules.some((rule) => normalizeHostRulePattern(rule.pattern) === pattern) - ) { + // Two spellings of one rule are one rule (`[::1]` and + // `[0:0:0:0:0:0:0:1]`, case, whitespace): compare what they match, not + // the text. Otherwise both would sit in the table and only one could + // ever apply. + const key = hostRulePatternKey(pattern) + if (rules.some((rule) => hostRulePatternKey(rule.pattern) === key)) { setProblem("duplicate") return } @@ -183,9 +185,9 @@ function HostRulesEditor({ return ( <div className="space-y-1.5"> - {managed.map((rule) => ( + {managed.map((rule, index) => ( <div - key={`managed:${rule.pattern}`} + key={`managed:${index}:${rule.pattern}`} className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/40 px-3 py-2" title={t("ruleManaged")} > diff --git a/src/lib/browser/host-rules.test.ts b/src/lib/browser/host-rules.test.ts index c5db77ff9c..45e8bd6472 100644 --- a/src/lib/browser/host-rules.test.ts +++ b/src/lib/browser/host-rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + hostRulePatternKey, isHostRule, matchHostRule, normalizeHostRulePattern, @@ -58,6 +59,9 @@ describe("host rule patterns", () => { "[::1]x", "[1::2::3]", "[not-an-address]", + "[*]", + "[*.example.com]", + "[*]:443", "*.*", ]) { expect(validateHostRulePattern(bad), bad).toBe("invalid") @@ -182,6 +186,8 @@ describe("matchHostRule", () => { expect(action("https://wiki.corp.example/")).toBe("builtin") expect(action("https://a.sso.corp.example/")).toBe("builtin") expect(action("https://elsewhere.example/")).toBe("system") + // Equal specificity: the more restrictive action, whatever the order — + // including two spellings of one host. expect( matchHostRule( [ @@ -190,6 +196,37 @@ describe("matchHostRule", () => { ], new URL("https://dup.example/") )?.action - ).toBe("builtin") + ).toBe("block") + expect( + matchHostRule( + [ + { pattern: "[0:0:0:0:0:0:0:1]", action: "system" }, + { pattern: "[::1]", action: "block" }, + ], + new URL("http://[::1]/") + )?.action + ).toBe("block") + // Equal in every respect: the first listed. + const same: HostRule[] = [ + { pattern: "dup.example", action: "system" }, + { pattern: "dup.example", action: "system" }, + ] + expect(matchHostRule(same, new URL("https://dup.example/"))).toBe(same[0]) + }) + + it("keys two spellings of one rule the same", () => { + expect(hostRulePatternKey("[0:0:0:0:0:0:0:1]:3000")).toBe( + hostRulePatternKey(" [::1]:3000 ") + ) + expect(hostRulePatternKey("Example.COM")).toBe( + hostRulePatternKey("example.com") + ) + expect(hostRulePatternKey("example.com")).not.toBe( + hostRulePatternKey("example.com:80") + ) + expect(hostRulePatternKey("*.example.com")).not.toBe( + hostRulePatternKey("example.com") + ) + expect(hostRulePatternKey("not a pattern")).toBeNull() }) }) diff --git a/src/lib/browser/host-rules.ts b/src/lib/browser/host-rules.ts index 77c318747b..88bd2ce9c1 100644 --- a/src/lib/browser/host-rules.ts +++ b/src/lib/browser/host-rules.ts @@ -100,17 +100,18 @@ export function parseHostRulePattern( if (!Number.isInteger(port) || port < 1 || port > 65535) return null } let matcher: HostMatcher - if (host === "*") { + if (bracketed) { + // Brackets mean an IPv6 literal and nothing else — not a wildcard, not + // a name. + const canonical = canonicalIpv6(host) + if (!canonical) return null + matcher = { kind: "exact", host: canonical } + } else if (host === "*") { matcher = { kind: "any" } } else if (host.startsWith("*.")) { const suffix = host.slice(2) if (!validHostname(suffix)) return null matcher = { kind: "suffix", suffix: `.${suffix}` } - } else if (bracketed) { - // Brackets mean an IPv6 literal and nothing else. - const canonical = canonicalIpv6(host) - if (!canonical) return null - matcher = { kind: "exact", host: canonical } } else if (validHostname(host)) { matcher = { kind: "exact", host } } else { @@ -168,11 +169,22 @@ function matches( return hostOk && (rule.port === null || rule.port === port) } +type Score = [number, number, number, number] + +/** Among equally specific rules the more restrictive one wins — two + * spellings of one host may both be in the table, and a block must not + * depend on which was listed first. */ +const RESTRICTIVENESS: Record<HostRuleAction, number> = { + block: 2, + system: 1, + builtin: 0, +} + /** * Higher wins: an exact host over a wildcard, a longer wildcard suffix over - * a shorter one, `*` last; a pinned port breaks a tie. + * a shorter one, `*` last; a pinned port breaks a tie; then the action. */ -function specificity(rule: ParsedHostRulePattern): [number, number, number] { +function score(rule: ParsedHostRulePattern, action: HostRuleAction): Score { const host = rule.host const [kind, len] = host.kind === "exact" @@ -180,23 +192,20 @@ function specificity(rule: ParsedHostRulePattern): [number, number, number] { : host.kind === "suffix" ? [1, host.suffix.length] : [0, 0] - return [kind, len, rule.port === null ? 0 : 1] + return [kind, len, rule.port === null ? 0 : 1, RESTRICTIVENESS[action]] } -function moreSpecific( - a: [number, number, number], - b: [number, number, number] -): boolean { - for (let i = 0; i < 3; i += 1) { +function higher(a: Score, b: Score): boolean { + for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return a[i] > b[i] } return false } /** - * The rule that applies to `parsed`: the most specific matching pattern, and - * among equally specific ones the first listed. Unparsable patterns never - * match. + * The rule that applies to `parsed`: the most specific matching pattern; + * among equally specific ones the most restrictive action, and among those + * the first listed. Unparsable patterns never match. */ export function matchHostRule( rules: readonly HostRule[] | undefined, @@ -206,16 +215,35 @@ export function matchHostRule( const hostname = ruleHostname(parsed) if (!hostname) return null const port = effectivePort(parsed) - let best: { rule: HostRule; score: [number, number, number] } | null = null + let best: { rule: HostRule; score: Score } | null = null for (const rule of rules) { const pattern = parseHostRulePattern(rule.pattern) if (!pattern || !matches(pattern, hostname, port)) continue - const score = specificity(pattern) - if (!best || moreSpecific(score, best.score)) best = { rule, score } + const candidate = score(pattern, rule.action) + if (!best || higher(candidate, best.score)) { + best = { rule, score: candidate } + } } return best?.rule ?? null } +/** + * What a pattern matches, as a key: two patterns with the same key are the + * same rule however they are spelled (`[::1]` / `[0:0:0:0:0:0:0:1]`, case, + * whitespace). `null` for anything that is not a pattern. + */ +export function hostRulePatternKey(pattern: string): string | null { + const parsed = parseHostRulePattern(pattern) + if (!parsed) return null + const host = + parsed.host.kind === "any" + ? "*" + : parsed.host.kind === "suffix" + ? `*${parsed.host.suffix}` + : parsed.host.host + return `${host}:${parsed.port ?? ""}` +} + /** Whether a value read from storage or the wire is a rule. */ export function isHostRule(value: unknown): value is HostRule { if (!value || typeof value !== "object") return false From c2e33ffa373443d17e9700463e1b670fd9f9ed55 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 15:31:15 +0800 Subject: [PATCH 27/79] fix(browser): trim patterns as ASCII on both sides; make visibility changes atomic `str::trim` and `String.prototype.trim` disagree on U+0085 and U+00A0; a pattern one side parsed and the other rejected was a rule the frontend silently ignored. Both sides now trim ASCII whitespace only. A visibility request takes the surface and its stamp in one registry operation and checks the stamp in the same operation that records the change, so a close-and-reopen of the same tab id cannot pair an old surface with the new tab's stamp; a tab's visibility lock is deleted while the tabs lock is still held, in both removal paths, so a tab inserted under the same id in between keeps its own lock. --- src-tauri/src/browser/policy.rs | 9 +++++++- src-tauri/src/browser/registry.rs | 35 +++++++++++++++++----------- src-tauri/src/commands/browser.rs | 37 ++++++++++++++++-------------- src/lib/browser/host-rules.test.ts | 9 ++++++++ src/lib/browser/host-rules.ts | 15 ++++++++---- 5 files changed, 69 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs index e111a78d27..364a7e6a55 100644 --- a/src-tauri/src/browser/policy.rs +++ b/src-tauri/src/browser/policy.rs @@ -91,7 +91,12 @@ struct ParsedPattern { /// Parse a pattern the way the frontend does (`parseHostRulePattern`); `None` /// for anything that is not a pattern, which then never matches. fn parse_pattern(pattern: &str) -> Option<ParsedPattern> { - let trimmed = pattern.trim().to_ascii_lowercase(); + // ASCII whitespace only, like the frontend: `str::trim` would also eat + // U+0085 and friends, and a pattern the two sides parse differently is + // a rule one of them silently ignores. + let trimmed = pattern + .trim_matches(|c: char| c.is_ascii_whitespace()) + .to_ascii_lowercase(); if trimmed.is_empty() || trimmed.len() > MAX_PATTERN_LEN { return None; } @@ -558,6 +563,8 @@ mod tests { "[*.example.com]", "[*]:443", "*.*", + "\u{85}blocked.example", + "blocked.example\u{a0}", ] { assert!(!valid_pattern(bad), "{bad:?} should not parse"); } diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index ae703f7ede..5b54639feb 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -183,25 +183,32 @@ impl BrowserRegistry { } pub fn remove(&self, tab_id: &str) -> Option<BrowserTab> { - self.visibility - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(tab_id); - self.lock().remove(tab_id) + // Lock order everywhere: tabs, then visibility (never the reverse), + // and the lock entry goes while the tabs lock is still held — a tab + // inserted under the same id in between would otherwise lose its + // own, freshly created lock. + let mut tabs = self.lock(); + let removed = tabs.remove(tab_id); + if removed.is_some() { + self.visibility + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(tab_id); + } + removed } /// Detach every tab owned by a window (called when that window is /// destroyed); the caller closes the returned surfaces. pub fn remove_by_owner(&self, owner_window: &str) -> Vec<BrowserTab> { - let removed: Vec<BrowserTab> = { - let mut tabs = self.lock(); - let ids: Vec<String> = tabs - .values() - .filter(|t| t.state.owner_window == owner_window) - .map(|t| t.state.tab_id.clone()) - .collect(); - ids.into_iter().filter_map(|id| tabs.remove(&id)).collect() - }; + let mut tabs = self.lock(); + let ids: Vec<String> = tabs + .values() + .filter(|t| t.state.owner_window == owner_window) + .map(|t| t.state.tab_id.clone()) + .collect(); + let removed: Vec<BrowserTab> = ids.into_iter().filter_map(|id| tabs.remove(&id)).collect(); + // Same order and same reason as `remove`: still under the tabs lock. let mut locks = self .visibility .lock() diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index dc90c20c9c..f630700ee3 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -449,40 +449,43 @@ pub async fn set_visible_core( handoff_focus: bool, freeze: bool, ) -> Result<Option<FrozenFrame>, AppCommandError> { - let surface = surface_of(registry, tab_id)?; - // Arrival order first, then the tab's lock: requests apply one at a + // The surface and the request's stamp (its sequence number and the + // tab's incarnation) are taken in ONE registry operation: taken apart, a + // close-and-reopen of the same id in between would pair the old surface + // with the new tab's stamp. Then the tab's lock: requests apply one at a // time and in the order they came, and one that a newer request has // overtaken while it waited or captured is dropped rather than applied // late (the newer one carries the state that stands). - let stamp = registry + let (surface, stamp) = registry .update(tab_id, |tab| { tab.visible_seq += 1; - (tab.visible_seq, tab.generation) + (tab.surface.clone(), (tab.visible_seq, tab.generation)) }) - .unwrap_or_default(); + .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; let lock = registry.visibility_lock(tab_id); let _applying = lock.lock().await; // Both the sequence and the incarnation: the id may have been closed and // reopened while this waited, and the new tab's own counter must not be // mistaken for ours. - let superseded = - || registry.update(tab_id, |tab| (tab.visible_seq, tab.generation)) != Some(stamp); - if superseded() { + let current = || registry.update(tab_id, |tab| (tab.visible_seq, tab.generation)); + if current() != Some(stamp) { return Ok(None); } let mut frame = None; if !visible && freeze && surface.is_embedded() { frame = capture_freeze_frame(&surface).await; - if superseded() { - return Ok(None); - } } - let bounds = registry - .update(tab_id, |tab| { - tab.visible = visible; - tab.last_bounds - }) - .unwrap_or_default(); + // Check and record in one operation, so nothing can come between the + // last look at the stamp and the state change it guards. + let Some(Some(bounds)) = registry.update(tab_id, |tab| { + if (tab.visible_seq, tab.generation) != stamp { + return None; + } + tab.visible = visible; + Some(tab.last_bounds) + }) else { + return Ok(None); + }; if visible { if surface.is_embedded() { surface diff --git a/src/lib/browser/host-rules.test.ts b/src/lib/browser/host-rules.test.ts index 45e8bd6472..0ef281b530 100644 --- a/src/lib/browser/host-rules.test.ts +++ b/src/lib/browser/host-rules.test.ts @@ -33,6 +33,15 @@ describe("host rule patterns", () => { } }) + it("trims ASCII whitespace only, like the Rust side", () => { + // `String.prototype.trim` and Rust's `str::trim` disagree on these; a + // pattern one side accepts and the other rejects is a silently dropped + // rule, so neither side accepts them. + expect(validateHostRulePattern("\u0085blocked.example")).toBe("invalid") + expect(validateHostRulePattern("blocked.example\u00a0")).toBe("invalid") + expect(validateHostRulePattern("\tblocked.example \n")).toBeNull() + }) + it("lower-cases ASCII only, like the Rust side", () => { // U+212A KELVIN SIGN folds to `k` under Unicode lower-casing; a pattern // is ASCII, so it is not a pattern on either side. diff --git a/src/lib/browser/host-rules.ts b/src/lib/browser/host-rules.ts index 88bd2ce9c1..aac065c75e 100644 --- a/src/lib/browser/host-rules.ts +++ b/src/lib/browser/host-rules.ts @@ -63,6 +63,13 @@ function asciiLower(text: string): string { return text.replace(/[A-Z]/g, (c) => c.toLowerCase()) } +/** ASCII-only trimming, like the Rust side (`String.prototype.trim` and + * `str::trim` disagree on U+0085, U+00A0 and more; a pattern the two sides + * parse differently is a rule one of them silently ignores). */ +function asciiTrim(text: string): string { + return text.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "") +} + /** * Parse a pattern; `null` for anything that is not one. Case-insensitive, * surrounding whitespace ignored. Same grammar as the Rust side. @@ -70,7 +77,7 @@ function asciiLower(text: string): string { export function parseHostRulePattern( pattern: string ): ParsedHostRulePattern | null { - const trimmed = asciiLower(pattern.trim()) + const trimmed = asciiLower(asciiTrim(pattern)) if (!trimmed || trimmed.length > MAX_PATTERN_LEN) return null let host: string let portText: string | null = null @@ -124,13 +131,13 @@ export function parseHostRulePattern( export function validateHostRulePattern( pattern: string ): "empty" | "invalid" | null { - if (!pattern.trim()) return "empty" + if (!asciiTrim(pattern)) return "empty" return parseHostRulePattern(pattern) ? null : "invalid" } -/** The form a pattern is stored in: trimmed and (ASCII) lower-cased. */ +/** The form a pattern is stored in: (ASCII) trimmed and lower-cased. */ export function normalizeHostRulePattern(pattern: string): string { - return asciiLower(pattern.trim()) + return asciiLower(asciiTrim(pattern)) } /** From 1324cdf7f76788656f456bcf7b3699cb476dd706 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 15:33:31 +0800 Subject: [PATCH 28/79] fix(browser): trim exactly the characters the backend trims Rust's is_ascii_whitespace does not include vertical tab; the frontend's trim class now matches it character for character. --- src/lib/browser/host-rules.test.ts | 4 ++++ src/lib/browser/host-rules.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/browser/host-rules.test.ts b/src/lib/browser/host-rules.test.ts index 0ef281b530..3679daf16a 100644 --- a/src/lib/browser/host-rules.test.ts +++ b/src/lib/browser/host-rules.test.ts @@ -39,7 +39,11 @@ describe("host rule patterns", () => { // rule, so neither side accepts them. expect(validateHostRulePattern("\u0085blocked.example")).toBe("invalid") expect(validateHostRulePattern("blocked.example\u00a0")).toBe("invalid") + // Vertical tab is not in Rust's `is_ascii_whitespace` either. + expect(validateHostRulePattern("\u000bblocked.example")).toBe("invalid") + expect(validateHostRulePattern("\u000b")).toBe("invalid") expect(validateHostRulePattern("\tblocked.example \n")).toBeNull() + expect(validateHostRulePattern("\fblocked.example\r")).toBeNull() }) it("lower-cases ASCII only, like the Rust side", () => { diff --git a/src/lib/browser/host-rules.ts b/src/lib/browser/host-rules.ts index aac065c75e..3f72de8a53 100644 --- a/src/lib/browser/host-rules.ts +++ b/src/lib/browser/host-rules.ts @@ -63,11 +63,13 @@ function asciiLower(text: string): string { return text.replace(/[A-Z]/g, (c) => c.toLowerCase()) } -/** ASCII-only trimming, like the Rust side (`String.prototype.trim` and - * `str::trim` disagree on U+0085, U+00A0 and more; a pattern the two sides - * parse differently is a rule one of them silently ignores). */ +/** Trimming of exactly the characters the Rust side trims + * (`char::is_ascii_whitespace`: space, tab, line feed, form feed, carriage + * return — and NOT vertical tab). `String.prototype.trim` and `str::trim` + * disagree on U+0085, U+00A0 and more; a pattern the two sides parse + * differently is a rule one of them silently ignores. */ function asciiTrim(text: string): string { - return text.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "") + return text.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "") } /** From a9125df407aa2c026d83b38896446072886e02aa Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 15:41:40 +0800 Subject: [PATCH 29/79] fix(browser): name the rule score type clippy's type_complexity on the (kind, length, port, restrictiveness) tuple; behaviour unchanged. --- src-tauri/src/browser/policy.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/browser/policy.rs b/src-tauri/src/browser/policy.rs index 364a7e6a55..3635f97b4a 100644 --- a/src-tauri/src/browser/policy.rs +++ b/src-tauri/src/browser/policy.rs @@ -218,6 +218,11 @@ impl HostRuleAction { } } +/// How well a rule fits a URL: specificity (kind, suffix length, pinned +/// port), then the action's restrictiveness. Higher wins; ties go to the +/// rule listed first. +type RuleScore = (u8, usize, u8, u8); + /// The rule that applies to `url`: the most specific matching pattern; among /// equally specific ones the most restrictive action, and among those the /// first listed. Unparsable patterns never match. Same algorithm as @@ -225,7 +230,7 @@ impl HostRuleAction { pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostRule> { let hostname = rule_hostname(url)?; let port = effective_port(url); - let mut best: Option<(&HostRule, (u8, usize, u8, u8))> = None; + let mut best: Option<(&HostRule, RuleScore)> = None; for rule in rules { let Some(parsed) = parse_pattern(&rule.pattern) else { continue; @@ -234,7 +239,7 @@ pub fn match_host_rule<'a>(rules: &'a [HostRule], url: &Url) -> Option<&'a HostR continue; } let (kind, len, pinned) = parsed.specificity(); - let score = (kind, len, pinned, rule.action.restrictiveness()); + let score: RuleScore = (kind, len, pinned, rule.action.restrictiveness()); if best.as_ref().is_none_or(|(_, current)| score > *current) { best = Some((rule, score)); } From 562fa3c0b183aa09c8d2015828d8ae3aacb4e715 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 17:07:43 +0800 Subject: [PATCH 30/79] feat(browser): optional action menu on terminal link clicks A new preference (off by default) makes a plain click on a web link in the terminal open a small menu at the click point - built-in browser, system browser, copy link - instead of following the per-source default. A modifier-click, a local path and a mailto: still open directly. The choice is made inside the menu item's own click, so a system-browser open in web mode keeps its user gesture. --- .../settings/browser-settings.test.tsx | 14 +++ src/components/settings/browser-settings.tsx | 17 +++ .../terminal/terminal-link-menu.test.tsx | 116 ++++++++++++++++++ .../terminal/terminal-link-menu.tsx | 102 +++++++++++++++ src/components/terminal/terminal-view.tsx | 33 ++++- src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/browser/browser-prefs.test.ts | 12 ++ src/lib/browser/browser-prefs.ts | 13 ++ 17 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 src/components/terminal/terminal-link-menu.test.tsx create mode 100644 src/components/terminal/terminal-link-menu.tsx diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index 2017baf452..dbacdcf59e 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -170,6 +170,20 @@ describe("BrowserSettingsSection", () => { expect(getBrowserPrefs().suspendBackgroundTabs).toBe(false) }) + it("persists the terminal link-menu switch, which is off by default", () => { + renderSection() + expandSection() + const toggle = screen.getByLabelText("Terminal link menu") + expect(toggle).not.toBeChecked() + + fireEvent.click(toggle) + expect(getBrowserPrefs().terminalClickMenu).toBe(true) + expect(screen.getByLabelText("Terminal link menu")).toBeChecked() + + fireEvent.click(screen.getByLabelText("Terminal link menu")) + expect(getBrowserPrefs().terminalClickMenu).toBe(false) + }) + it("persists the inspector switch and follows a change made elsewhere", () => { renderSection() expandSection() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index cb6bbf49ce..884a0a5589 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -24,6 +24,7 @@ import { ListFilter, Lock, MoonStar, + MousePointerClick, Network, Plus, Trash2, @@ -64,6 +65,7 @@ import { setBrowserHostRules, setBrowserSurfaceOverride, setBrowserSuspendBackgroundTabs, + setBrowserTerminalClickMenu, setDefaultLinkTarget, useBrowserPrefs, type LinkSource, @@ -437,6 +439,21 @@ export function BrowserSettingsSection() { </SettingCard> <SettingCard> + <SettingRow + icon={MousePointerClick} + title={t("terminalMenuTitle")} + description={t("terminalMenuHint")} + htmlFor="browser-terminal-menu" + control={ + <Switch + id="browser-terminal-menu" + checked={prefs.terminalClickMenu} + onCheckedChange={(enabled) => + setBrowserTerminalClickMenu(enabled) + } + /> + } + /> <SettingRow icon={Wrench} title={t("devtoolsTitle")} diff --git a/src/components/terminal/terminal-link-menu.test.tsx b/src/components/terminal/terminal-link-menu.test.tsx new file mode 100644 index 0000000000..030727c8a4 --- /dev/null +++ b/src/components/terminal/terminal-link-menu.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + copyTextToClipboard: vi.fn(() => Promise.resolve(true)), +})) + +vi.mock("@/lib/utils", async (importOriginal) => { + const actual = await importOriginal<typeof import("@/lib/utils")>() + return { ...actual, copyTextToClipboard: mocks.copyTextToClipboard } +}) + +import enMessages from "@/i18n/messages/en.json" + +import { + TerminalLinkMenu, + terminalLinkClickOpensMenu, +} from "./terminal-link-menu" + +function renderMenu(props: { + onChoose?: (url: string, target: "builtin" | "system") => void + onClose?: () => void +}) { + const onChoose = props.onChoose ?? vi.fn() + const onClose = props.onClose ?? vi.fn() + render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <TerminalLinkMenu + click={{ url: "https://example.com/a", x: 40, y: 60 }} + onChoose={onChoose} + onClose={onClose} + /> + </NextIntlClientProvider> + ) + return { onChoose, onClose } +} + +beforeEach(() => { + mocks.copyTextToClipboard.mockClear() +}) + +describe("terminalLinkClickOpensMenu", () => { + it("opens only for a plain click on a web address with the menu turned on", () => { + const on = { terminalClickMenu: true } + const off = { terminalClickMenu: false } + expect(terminalLinkClickOpensMenu(on, false, "https://example.com")).toBe( + true + ) + expect(terminalLinkClickOpensMenu(on, false, "http://localhost:3000")).toBe( + true + ) + // The preference is off: the link follows the per-source default. + expect(terminalLinkClickOpensMenu(off, false, "https://example.com")).toBe( + false + ) + // A modifier-click already chose the other destination. + expect(terminalLinkClickOpensMenu(on, true, "https://example.com")).toBe( + false + ) + // Not a web address: there is nothing to choose from. + expect(terminalLinkClickOpensMenu(on, false, "mailto:a@example.com")).toBe( + false + ) + expect(terminalLinkClickOpensMenu(on, false, "/tmp/report.txt")).toBe(false) + }) +}) + +describe("TerminalLinkMenu", () => { + it("renders nothing without a pending click", () => { + const { container } = render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <TerminalLinkMenu click={null} onChoose={vi.fn()} onClose={vi.fn()} /> + </NextIntlClientProvider> + ) + expect(container).toBeEmptyDOMElement() + }) + + it("offers the three actions at the click point and reports the choice", () => { + const { onChoose } = renderMenu({}) + const anchor = document.querySelector<HTMLElement>( + "[data-terminal-link-anchor]" + ) + expect(anchor?.style.left).toBe("40px") + expect(anchor?.style.top).toBe("60px") + + fireEvent.click( + screen.getByRole("menuitem", { name: "Open in built-in browser" }) + ) + expect(onChoose).toHaveBeenCalledWith("https://example.com/a", "builtin") + }) + + it("chooses the system browser explicitly", () => { + const { onChoose } = renderMenu({}) + fireEvent.click( + screen.getByRole("menuitem", { name: "Open in system browser" }) + ) + expect(onChoose).toHaveBeenCalledWith("https://example.com/a", "system") + }) + + it("copies the link without opening it", () => { + const { onChoose } = renderMenu({}) + fireEvent.click(screen.getByRole("menuitem", { name: "Copy link" })) + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith( + "https://example.com/a" + ) + expect(onChoose).not.toHaveBeenCalled() + }) + + it("reports Escape as a close", () => { + const { onClose, onChoose } = renderMenu({}) + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }) + expect(onClose).toHaveBeenCalled() + expect(onChoose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/terminal/terminal-link-menu.tsx b/src/components/terminal/terminal-link-menu.tsx new file mode 100644 index 0000000000..0be5daadec --- /dev/null +++ b/src/components/terminal/terminal-link-menu.tsx @@ -0,0 +1,102 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Copy, ExternalLink, PanelRight } from "lucide-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import type { + BrowserPrefsSnapshot, + LinkTarget, +} from "@/lib/browser/browser-prefs" +import { classifyLinkTarget } from "@/lib/link-classify" +import { copyTextToClipboard } from "@/lib/utils" + +/** A click on a terminal link that is waiting for the user to say where it + * goes. The menu opens at the click's viewport coordinates. */ +export interface TerminalLinkClick { + url: string + x: number + y: number +} + +/** + * Whether a click on `url` in the terminal opens the action menu instead of + * following the link at once: only when the user turned the menu on, only for + * a plain click (a modifier-click already says which way the link goes), and + * only for a web address — a local path or a `mailto:` has one destination + * and nothing to choose from. + */ +export function terminalLinkClickOpensMenu( + prefs: Pick<BrowserPrefsSnapshot, "terminalClickMenu">, + modifier: boolean, + url: string +): boolean { + return ( + prefs.terminalClickMenu && + !modifier && + classifyLinkTarget(url).kind === "http" + ) +} + +/** + * The menu itself: built-in browser, system browser, copy. Anchored to a + * zero-size element at the click point, so the menu opens where the click + * was, like a context menu. The choice is made inside the item's own click, + * which is the user gesture a system-browser open needs in web mode. + */ +export function TerminalLinkMenu({ + click, + onChoose, + onClose, +}: { + click: TerminalLinkClick | null + /** An explicit destination for the link; bypasses the per-source + * preference (never the site rules). */ + onChoose: (url: string, target: LinkTarget) => void + /** The menu went away, by a choice, Escape or a click elsewhere. */ + onClose: () => void +}) { + const t = useTranslations("Browser.link") + if (!click) return null + return ( + <DropdownMenu + open + onOpenChange={(open) => { + if (!open) onClose() + }} + > + <DropdownMenuTrigger asChild> + <span + aria-hidden + data-terminal-link-anchor="" + className="fixed h-0 w-0" + style={{ left: click.x, top: click.y }} + /> + </DropdownMenuTrigger> + <DropdownMenuContent + align="start" + // Focus goes back to the terminal (the caller's job), not to the + // invisible anchor. + onCloseAutoFocus={(event) => event.preventDefault()} + > + <DropdownMenuItem onSelect={() => onChoose(click.url, "builtin")}> + <PanelRight className="h-3.5 w-3.5" /> + {t("openBuiltin")} + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => onChoose(click.url, "system")}> + <ExternalLink className="h-3.5 w-3.5" /> + {t("openSystem")} + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => void copyTextToClipboard(click.url)}> + <Copy className="h-3.5 w-3.5" /> + {t("copy")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 1952bd2e9a..4af2c30246 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -23,6 +23,12 @@ import { type TermMods, } from "@/lib/terminal/keybar" import { TermKeybar } from "@/components/terminal/term-keybar" +import { + TerminalLinkMenu, + terminalLinkClickOpensMenu, + type TerminalLinkClick, +} from "@/components/terminal/terminal-link-menu" +import { getBrowserPrefs } from "@/lib/browser/browser-prefs" import { useZoomLevel, useTerminalFont } from "@/hooks/use-appearance" import { isPrimaryModifier, @@ -126,6 +132,9 @@ export function TerminalView({ // `window.open`, which the desktop webview turns into a dead click. const openUrlTarget = useOpenUrlTarget() const openUrlTargetRef = useRef(openUrlTarget) + // A link click held for the action menu (optional, off by default): the + // user picks the destination instead of the per-source preference. + const [linkClick, setLinkClick] = useState<TerminalLinkClick | null>(null) const { zoomLevel: appZoomLevel } = useZoomLevel() // 100 = 「不缩放」。画布卡片走这条:它已经在自己那套缩放里了。 const zoomLevel = ignoreAppZoom ? 100 : appZoomLevel @@ -248,10 +257,12 @@ export function TerminalView({ const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon((event, uri) => { - openUrlTargetRef.current(uri, { - source: "terminal", - modifier: isPrimaryModifier(event), - }) + const modifier = isPrimaryModifier(event) + if (terminalLinkClickOpensMenu(getBrowserPrefs(), modifier, uri)) { + setLinkClick({ url: uri, x: event.clientX, y: event.clientY }) + return + } + openUrlTargetRef.current(uri, { source: "terminal", modifier }) }) const term = new Terminal({ @@ -670,6 +681,20 @@ export function TerminalView({ /> )} </div> + <TerminalLinkMenu + click={linkClick} + onChoose={(url, target) => { + setLinkClick(null) + openUrlTargetRef.current(url, { + source: "terminal", + forceTarget: target, + }) + }} + onClose={() => { + setLinkClick(null) + termRef.current?.focus() + }} + /> {loading && isActive && ( <div className="absolute inset-0 flex items-center justify-center bg-background/80"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 316b0c401b..7771ca8559 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "ليس نمطًا صالحًا", "ruleDuplicate": "توجد قاعدة لهذا النمط بالفعل", "rulesEmpty": "لا توجد قواعد بعد.", + "terminalMenuTitle": "قائمة روابط الطرفية", + "terminalMenuHint": "عند النقر على رابط في الطرفية تظهر قائمة صغيرة (المتصفح المدمج، متصفح النظام، نسخ الرابط) بدلاً من فتحه فورًا. لا يزال النقر مع ⌘/Ctrl يفتح الرابط مباشرة.", "managedDisabled": "أوقفت سياسة المسؤول المتصفح المدمج؛ تُفتح الروابط في متصفح النظام." }, "LogsSettings": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9fa59602a7..f0f4e93ffa 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "Kein gültiges Muster", "ruleDuplicate": "Für dieses Muster gibt es bereits eine Regel", "rulesEmpty": "Noch keine Regeln.", + "terminalMenuTitle": "Link-Menü im Terminal", + "terminalMenuHint": "Ein Klick auf einen Link im Terminal zeigt ein kleines Menü (integrierter Browser, Systembrowser, Link kopieren), statt ihn sofort zu öffnen. ⌘/Strg-Klick öffnet den Link weiterhin direkt.", "managedDisabled": "Die Richtlinie der Administration hat den integrierten Browser abgeschaltet; Links öffnen sich im Systembrowser." }, "LogsSettings": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d5861d8226..8f8424fece 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "Not a valid pattern", "ruleDuplicate": "There is already a rule for this pattern", "rulesEmpty": "No rules yet.", + "terminalMenuTitle": "Terminal link menu", + "terminalMenuHint": "Clicking a link in the terminal shows a small menu (built-in browser, system browser, copy link) instead of opening it right away. ⌘/Ctrl-click still opens the link directly.", "managedDisabled": "The built-in browser is turned off by your administrator's policy; links open in the system browser." }, "LogsSettings": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 140fd16429..8948720e6a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "No es un patrón válido", "ruleDuplicate": "Ya existe una regla para este patrón", "rulesEmpty": "Aún no hay reglas.", + "terminalMenuTitle": "Menú de enlaces del terminal", + "terminalMenuHint": "Al hacer clic en un enlace del terminal se muestra un pequeño menú (navegador integrado, navegador del sistema, copiar enlace) en lugar de abrirlo de inmediato. ⌘/Ctrl+clic sigue abriendo el enlace directamente.", "managedDisabled": "La política de tu administrador ha desactivado el navegador integrado; los enlaces se abren en el navegador del sistema." }, "LogsSettings": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index cca3ffb6f4..6c8b5eccbc 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "Motif non valide", "ruleDuplicate": "Une règle existe déjà pour ce motif", "rulesEmpty": "Aucune règle pour l'instant.", + "terminalMenuTitle": "Menu des liens du terminal", + "terminalMenuHint": "Un clic sur un lien dans le terminal affiche un petit menu (navigateur intégré, navigateur système, copier le lien) au lieu de l'ouvrir immédiatement. ⌘/Ctrl-clic ouvre toujours le lien directement.", "managedDisabled": "La politique de votre administrateur a désactivé le navigateur intégré ; les liens s'ouvrent dans le navigateur système." }, "LogsSettings": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 121ee7570a..fd84b569cd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "有効なパターンではありません", "ruleDuplicate": "このパターンのルールは既にあります", "rulesEmpty": "ルールはまだありません。", + "terminalMenuTitle": "ターミナルのリンクメニュー", + "terminalMenuHint": "ターミナル内のリンクをクリックすると、すぐに開く代わりに小さなメニュー(内蔵ブラウザ、システムブラウザ、リンクをコピー)を表示します。⌘/Ctrl クリックは引き続き直接開きます。", "managedDisabled": "管理者のポリシーにより内蔵ブラウザは無効です。リンクはシステムブラウザで開きます。" }, "LogsSettings": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 4c9744ecb6..125eb5a858 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "올바른 패턴이 아닙니다", "ruleDuplicate": "이 패턴에 대한 규칙이 이미 있습니다", "rulesEmpty": "아직 규칙이 없습니다.", + "terminalMenuTitle": "터미널 링크 메뉴", + "terminalMenuHint": "터미널에서 링크를 클릭하면 바로 열지 않고 작은 메뉴(내장 브라우저, 시스템 브라우저, 링크 복사)를 표시합니다. ⌘/Ctrl 클릭은 여전히 바로 엽니다.", "managedDisabled": "관리자 정책으로 내장 브라우저가 꺼져 있습니다. 링크는 시스템 브라우저에서 열립니다." }, "LogsSettings": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index dff9a015bc..3885d12b45 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "Não é um padrão válido", "ruleDuplicate": "Já existe uma regra para este padrão", "rulesEmpty": "Ainda não há regras.", + "terminalMenuTitle": "Menu de links do terminal", + "terminalMenuHint": "Clicar em um link no terminal mostra um pequeno menu (navegador integrado, navegador do sistema, copiar link) em vez de abri-lo imediatamente. ⌘/Ctrl+clique continua abrindo o link diretamente.", "managedDisabled": "A política do seu administrador desativou o navegador integrado; os links abrem no navegador do sistema." }, "LogsSettings": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index ad06843de5..8e1b1fc4cb 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "不是有效的模式", "ruleDuplicate": "已有针对该模式的规则", "rulesEmpty": "还没有规则。", + "terminalMenuTitle": "终端链接菜单", + "terminalMenuHint": "点击终端里的链接时先弹出一个小菜单(内置浏览器、系统浏览器、复制链接),而不是直接打开。⌘/Ctrl 点击仍会直接打开。", "managedDisabled": "管理员策略已关闭内置浏览器;链接将在系统浏览器中打开。" }, "LogsSettings": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 862770d723..c5f0f9874c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4793,6 +4793,8 @@ "ruleInvalid": "不是有效的模式", "ruleDuplicate": "已有針對該模式的規則", "rulesEmpty": "還沒有規則。", + "terminalMenuTitle": "終端機連結選單", + "terminalMenuHint": "點擊終端機中的連結時先顯示一個小選單(內建瀏覽器、系統瀏覽器、複製連結),而不是直接開啟。⌘/Ctrl 點擊仍會直接開啟。", "managedDisabled": "管理員原則已關閉內建瀏覽器;連結將在系統瀏覽器中開啟。" }, "LogsSettings": { diff --git a/src/lib/browser/browser-prefs.test.ts b/src/lib/browser/browser-prefs.test.ts index 1050a6ee34..7b97058282 100644 --- a/src/lib/browser/browser-prefs.test.ts +++ b/src/lib/browser/browser-prefs.test.ts @@ -10,6 +10,7 @@ import { setBrowserDevtools, setBrowserHostRules, setBrowserSurfaceOverride, + setBrowserTerminalClickMenu, setDefaultLinkTarget, subscribeBrowserPrefs, useBrowserPrefs, @@ -150,6 +151,17 @@ describe("browser prefs", () => { expect(localStorage.getItem("browser:host-rules")).toBeNull() }) + it("stores the terminal link-menu switch under its own key, off by default", () => { + expect(getBrowserPrefs().terminalClickMenu).toBe(false) + setBrowserTerminalClickMenu(true) + expect(localStorage.getItem("browser:terminal-click-menu")).toBe("true") + expect(getBrowserPrefs().terminalClickMenu).toBe(true) + // Off is the default, so off removes the key rather than storing it. + setBrowserTerminalClickMenu(false) + expect(localStorage.getItem("browser:terminal-click-menu")).toBeNull() + expect(getBrowserPrefs().terminalClickMenu).toBe(false) + }) + it("useBrowserPrefs re-renders on change", () => { const { result } = renderHook(() => useBrowserPrefs()) expect(result.current.defaultTarget.toolCard).toBe("builtin") diff --git a/src/lib/browser/browser-prefs.ts b/src/lib/browser/browser-prefs.ts index 972cdf870b..3365bc8f0c 100644 --- a/src/lib/browser/browser-prefs.ts +++ b/src/lib/browser/browser-prefs.ts @@ -44,6 +44,11 @@ export interface BrowserPrefsSnapshot { /** Per-site overrides of the default target, and outright blocks. The * administrator's rules (from the backend's policy) are not in here. */ hostRules: readonly HostRule[] + /** A plain click on a terminal link opens a small menu (built-in browser, + * system browser, copy) instead of following the link. Off by default: + * one more click on every link is only worth it to people who switch + * destinations often; a modifier-click already offers the other one. */ + terminalClickMenu: boolean } export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ @@ -59,6 +64,7 @@ export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ firstOpenSeen: false, suspendBackgroundTabs: false, hostRules: Object.freeze([]) as readonly HostRule[], + terminalClickMenu: false, }) as BrowserPrefsSnapshot const KEY_PREFIX = "browser:" @@ -74,6 +80,7 @@ const SUSPEND_KEY = `${KEY_PREFIX}suspend-background-tabs` // One key for the whole table: a rule list is one setting, edited in one // place, and half a table is not a meaningful state. const HOST_RULES_KEY = `${KEY_PREFIX}host-rules` +const TERMINAL_MENU_KEY = `${KEY_PREFIX}terminal-click-menu` function readRaw(key: string): string | null { if (typeof window === "undefined") return null @@ -122,6 +129,7 @@ function read(): BrowserPrefsSnapshot { firstOpenSeen: readRaw(FIRST_OPEN_KEY) === "true", suspendBackgroundTabs: readRaw(SUSPEND_KEY) === "true", hostRules: parseHostRules(readRaw(HOST_RULES_KEY)), + terminalClickMenu: readRaw(TERMINAL_MENU_KEY) === "true", } } @@ -181,6 +189,10 @@ export function setBrowserHostRules(rules: readonly HostRule[]): void { write(HOST_RULES_KEY, cleaned.length > 0 ? JSON.stringify(cleaned) : null) } +export function setBrowserTerminalClickMenu(enabled: boolean): void { + write(TERMINAL_MENU_KEY, enabled ? "true" : null) +} + export function subscribeBrowserPrefs(listener: () => void): () => void { if (typeof window === "undefined") return () => {} const onChange = () => listener() @@ -224,6 +236,7 @@ export function resetBrowserPrefsForTests(): void { localStorage.removeItem(FIRST_OPEN_KEY) localStorage.removeItem(SUSPEND_KEY) localStorage.removeItem(HOST_RULES_KEY) + localStorage.removeItem(TERMINAL_MENU_KEY) } catch { /* ignore */ } From 2c2b894c5bb62e5498439e4e0c9eb3fef5ad1d0c Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Tue, 8 Sep 2026 18:01:10 +0800 Subject: [PATCH 31/79] feat(browser): show HTML files through a codeg-doc document guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An .html file opened in the file pane is now rendered by the built-in browser in a document view served straight from the file's folder, in place of the inline srcdoc preview (which stays one menu choice away and remains the web build's renderer). Backend (doc_guest.rs): a `codeg-doc:` scheme handler registered on the guest webview's own builder and bound to one grant (root + entry). Every request is confined to the root — canonicalized, no symlink escape, no directory listings, the final component opened without following a symlink — and answered with nosniff / no-referrer / no-store headers and the mode's CSP. Safe mode (default) runs no script and opens no connection; dynamic mode, chosen per file for the session, runs the document's scripts with the root as the only reachable endpoint. Dynamic mode records its approval time and pins the hash of every file it serves: a file newer than the approval or different from what was served is refused, the guest drops back to safe mode, reports which file, and reloads the document under the safe policy. surface_child: ChildKind::{Page, Document}. A document guest gets the guest's navigation policy (its own documents only; a web address is reported as `external` so the user can open it in a tab; pop-ups and downloads refused), a non-persistent data store, and the handler. Commands browser_doc_open / browser_doc_set_mode / browser_doc_state; `kind` on the tab state; `docGuest` capability (macOS for now). Frontend: NativeSurfaceHost generalized from BrowserSurfaceHost (a document guest is torn down when its preview unmounts and recreated, under the same id, when it returns). DocGuestPreview hosts the guest in the HTML preview slot — file column, viewer drawer, canvas card switch at once — with the scripts switch, reload, notices (external link, mailto, reset) and a per-file inline fallback; HtmlPreview picks the engine from the capability, the new setting and the per-file choice. Settings row and strings in 10 locales. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/browser/doc_guest.rs | 926 ++++++++++++++++++ src-tauri/src/browser/events.rs | 9 +- src-tauri/src/browser/hooks.rs | 3 +- src-tauri/src/browser/mod.rs | 3 + src-tauri/src/browser/shim/macos.rs | 13 + src-tauri/src/browser/smoke.rs | 46 + src-tauri/src/browser/surface_child.rs | 216 +++- src-tauri/src/browser/types.rs | 24 + src-tauri/src/commands/browser.rs | 248 ++++- src-tauri/src/commands/folders.rs | 6 +- src-tauri/src/lib.rs | 4 + .../ai-elements/link-safety.test.tsx | 1 + .../browser/browser-events-bridge.test.tsx | 8 + .../browser/browser-events-bridge.tsx | 8 + .../browser/browser-status-layer.tsx | 5 + .../browser/browser-surface-host.test.tsx | 41 +- .../browser/browser-surface-host.tsx | 141 ++- .../browser/browser-tabs-persistence.test.tsx | 4 + .../browser/browser-tabs-suspender.test.tsx | 1 + .../files/doc-guest-preview.test.tsx | 353 +++++++ src/components/files/doc-guest-preview.tsx | 368 +++++++ src/components/files/html-preview.test.tsx | 139 +++ src/components/files/html-preview.tsx | 152 ++- .../settings/browser-settings.test.tsx | 28 + src/components/settings/browser-settings.tsx | 23 + src/contexts/workspace-context.test.tsx | 2 + src/hooks/use-open-url-target.test.tsx | 1 + src/i18n/messages/ar.json | 22 + src/i18n/messages/de.json | 22 + src/i18n/messages/en.json | 22 + src/i18n/messages/es.json | 22 + src/i18n/messages/fr.json | 22 + src/i18n/messages/ja.json | 22 + src/i18n/messages/ko.json | 22 + src/i18n/messages/pt.json | 22 + src/i18n/messages/zh-CN.json | 22 + src/i18n/messages/zh-TW.json | 22 + src/lib/browser/browser-api.ts | 52 + src/lib/browser/browser-prefs.test.ts | 11 + src/lib/browser/browser-prefs.ts | 17 + .../browser/browser-tab-persistence.test.ts | 1 + src/lib/browser/browser-tab-store.doc.test.ts | 52 + src/lib/browser/browser-tab-store.test.ts | 1 + src/lib/browser/browser-tab-store.ts | 39 +- src/lib/browser/types.test.ts | 2 + src/lib/browser/types.ts | 48 +- src/lib/browser/use-browser-capabilities.ts | 29 + 49 files changed, 3147 insertions(+), 100 deletions(-) create mode 100644 src-tauri/src/browser/doc_guest.rs create mode 100644 src/components/files/doc-guest-preview.test.tsx create mode 100644 src/components/files/doc-guest-preview.tsx create mode 100644 src/components/files/html-preview.test.tsx create mode 100644 src/lib/browser/browser-tab-store.doc.test.ts create mode 100644 src/lib/browser/use-browser-capabilities.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cde5b88dd5..0c20a627e6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1081,6 +1081,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "objc2-web-kit", + "percent-encoding", "portable-pty", "prost", "qrcode", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5527ca7b7e..f48f5e7e5c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -139,6 +139,7 @@ tower-http = { version = "0.6", features = ["fs", "cors", "compression-gzip", "c # Direct deps (already in the graph via axum) so the compression predicate can # name the exact types tower-http's `Predicate` trait is defined over. http = "1" +percent-encoding = "2" http-body = "1" tokio-tungstenite = { version = "0.26", features = ["native-tls"] } futures-util = "0.3" diff --git a/src-tauri/src/browser/doc_guest.rs b/src-tauri/src/browser/doc_guest.rs new file mode 100644 index 0000000000..66c842c9b7 --- /dev/null +++ b/src-tauri/src/browser/doc_guest.rs @@ -0,0 +1,926 @@ +//! The `codeg-doc:` document guest: a local HTML file shown through a webview +//! of its own, whose every request the host answers from the file's folder. +//! This is what the desktop shows for an `.html` file instead of an inline +//! `srcdoc` preview: a real document with a real URL, so routing, `fetch` of +//! sibling files and relative links work — inside a fence. +//! +//! The fence, in order of importance: +//! +//! - **Only guests have the scheme.** The handler is registered on the guest +//! webview's own builder, so the app's webview and ordinary browser tabs +//! cannot address `codeg-doc:` at all; and the handler is bound to one +//! grant (root + entry) and checks the webview id it is called for. +//! - **Only files under the root.** Same rule as the inline preview: the root +//! is the workspace folder the file sits in (else its own directory), the +//! path is canonicalized and confined, and the final component is opened +//! without following a symlink. No directory listings. +//! - **Safe mode by default.** The document is served with a CSP that runs +//! no script and opens no connection; images, styles and fonts come from +//! the root only. **Dynamic mode** is a per-file, per-session decision by +//! the user: scripts run, but still only from the root, and the only +//! endpoint they can reach is the root (`connect-src 'self'`). +//! - **What was approved is what runs.** Dynamic mode records when it was +//! granted; a file whose timestamps are newer than that, or whose content +//! differs from what was served since, drops the guest back to safe mode +//! and is not served. The document the user approved cannot be swapped +//! underneath the approval. +//! - **A guest goes nowhere else.** Top-level navigation to a web address is +//! refused and reported so the user can open it in a browser tab; +//! `window.open` and downloads are refused; the data store is +//! non-persistent and dies with the guest. +//! +//! A grant lives for the session: switching files and coming back keeps the +//! mode the user chose (and the approval time it was chosen at). + +use std::collections::HashMap; +use std::fs::{File, Metadata}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use http::{header, Request, Response, StatusCode, Uri}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::Url; + +/// Label prefix of every document guest webview. Like `browser-`, it must +/// never appear in a capability (`mod.rs` has the test). +pub const DOC_LABEL_PREFIX: &str = "codeg-doc-"; +pub const DOC_SCHEME: &str = "codeg-doc"; +/// The host part of every document URL. Constant on purpose: the grant is +/// bound to the webview, not carried in the URL, and a constant origin keeps +/// `'self'` in the CSP meaning "this guest's root" on every platform. +pub const DOC_HOST: &str = "doc"; + +/// Largest file the guest serves. A document preview that needs more than +/// this in one response is not a document; the body is held in memory. +const MAX_BODY_BYTES: u64 = 512 * 1024 * 1024; +/// A range request is answered at most this large per response; the engine +/// asks for the rest as it needs it. +const MAX_RANGE_BYTES: u64 = 16 * 1024 * 1024; + +pub fn doc_label(tab_id: &str) -> String { + format!("{DOC_LABEL_PREFIX}{tab_id}") +} + +/// Whether this build can host document guests: the embedded surface with a +/// per-webview scheme handler exists on macOS; the Windows and Linux shims +/// have not been exercised, and the inline preview stays in place there. +pub fn supported() -> bool { + cfg!(all( + feature = "browser-child", + target_os = "macos" + )) +} + +/// A URL that addresses this guest's own root. wry maps a custom scheme to +/// `http(s)://<scheme>.<host>` on Windows, so both spellings count. +pub fn is_document_url(url: &Url) -> bool { + url.scheme() == DOC_SCHEME + || (matches!(url.scheme(), "http" | "https") + && url + .host_str() + .is_some_and(|host| host.starts_with(&format!("{DOC_SCHEME}.")))) +} + +/// What a guest may navigate to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestNavigation { + Allow, + /// A web address: not for the guest, but the user may want it in a tab. + External, + /// Anything else (`mailto:`, `file:`, app schemes, `data:` documents). + Scheme, +} + +/// The guest's navigation policy: its own documents, the blank page, and — +/// inside its own frames only — the opaque-origin content a page composes +/// itself. Everything with a scheme of its own stays out; web addresses are +/// reported as such so the user can follow them elsewhere. +pub fn guest_navigation(url: &Url, main_frame: bool) -> GuestNavigation { + if is_document_url(url) { + return GuestNavigation::Allow; + } + match url.scheme() { + "about" if url.as_str() == "about:blank" => GuestNavigation::Allow, + "about" if !main_frame && url.as_str() == "about:srcdoc" => GuestNavigation::Allow, + "data" | "blob" if !main_frame => GuestNavigation::Allow, + "http" | "https" => GuestNavigation::External, + _ => GuestNavigation::Scheme, + } +} + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DocMode { + /// No script, no connection; the document as a picture of itself. + Safe, + /// Scripts from the root run and may fetch from the root. + Dynamic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DocResetReason { + /// The file's timestamps are newer than the approval. + Newer, + /// The file's content differs from what was served since the approval. + Changed, +} + +/// Why a guest fell back to safe mode on its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocReset { + /// The offending file, relative to the root. + pub path: String, + pub reason: DocResetReason, +} + +/// Mode and status of one guest, emitted on `browser://doc-state`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocGuestState { + pub tab_id: String, + pub mode: DocMode, + /// Absolute directory every request is confined to. + pub root: String, + /// Absolute path of the document. + pub entry: String, + /// The document's URL inside the guest. + pub url: String, + /// Set after the guest dropped back to safe mode by itself; cleared by + /// the next explicit mode change. + pub reset: Option<DocReset>, +} + +// --------------------------------------------------------------------------- +// Grants +// --------------------------------------------------------------------------- + +/// The state of dynamic mode: when it was granted and what has been served +/// since (path → SHA-256 of the bytes served). +struct Approval { + approved_at: SystemTime, + pins: HashMap<PathBuf, [u8; 32]>, +} + +struct GrantInner { + /// `None` = safe mode. + approval: Option<Approval>, + reset: Option<DocReset>, +} + +/// One document: its root, its entry file, and the mode the user chose. +pub struct DocGrant { + root: PathBuf, + entry: PathBuf, + entry_rel: PathBuf, + inner: Mutex<GrantInner>, +} + +/// Where a document lives, from what the frontend knows: the file, and the +/// root it should be confined to (the owning workspace folder). Both must +/// exist; the root falls back to the file's own directory when it is not +/// given or does not contain the file. Canonical paths come back. +pub fn resolve_document(path: &str, root: Option<&str>) -> Result<(PathBuf, PathBuf), String> { + let entry = PathBuf::from(path); + if !entry.is_absolute() { + return Err(format!("document path must be absolute: {path:?}")); + } + let entry = std::fs::canonicalize(&entry).map_err(|e| format!("cannot open {path:?}: {e}"))?; + if !entry.is_file() { + return Err(format!("not a file: {path:?}")); + } + let parent = entry + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| format!("document has no directory: {path:?}"))?; + let root = match root { + Some(root) => match std::fs::canonicalize(root) { + Ok(root) if root.is_dir() && entry.starts_with(&root) => root, + _ => parent, + }, + None => parent, + }; + Ok((root, entry)) +} + +impl DocGrant { + /// `root` and `entry` canonical, `entry` under `root`. + pub fn new(root: PathBuf, entry: PathBuf) -> Result<Self, String> { + let entry_rel = entry + .strip_prefix(&root) + .map_err(|_| format!("{} is not under {}", entry.display(), root.display()))? + .to_path_buf(); + Ok(Self { + root, + entry, + entry_rel, + inner: Mutex::new(GrantInner { + approval: None, + reset: None, + }), + }) + } + + fn lock(&self) -> MutexGuard<'_, GrantInner> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn entry(&self) -> &Path { + &self.entry + } + + pub fn mode(&self) -> DocMode { + if self.lock().approval.is_some() { + DocMode::Dynamic + } else { + DocMode::Safe + } + } + + /// The user's decision. Dynamic mode starts a fresh approval — nothing + /// served before it counts — and either way a pending reset is done with. + pub fn set_mode(&self, mode: DocMode) { + let mut inner = self.lock(); + inner.reset = None; + inner.approval = match mode { + DocMode::Safe => None, + DocMode::Dynamic => Some(Approval { + approved_at: SystemTime::now(), + pins: HashMap::new(), + }), + }; + } + + /// The entry document's URL inside the guest. + pub fn document_url(&self) -> String { + document_url(&self.entry_rel) + } + + pub fn state(&self, tab_id: &str) -> DocGuestState { + let inner = self.lock(); + DocGuestState { + tab_id: tab_id.to_string(), + mode: if inner.approval.is_some() { + DocMode::Dynamic + } else { + DocMode::Safe + }, + root: self.root.to_string_lossy().into_owned(), + entry: self.entry.to_string_lossy().into_owned(), + url: self.document_url(), + reset: inner.reset.clone(), + } + } + + /// Answer one request from the guest. Never fails: every outcome is a + /// response, and `reset` says when this request ended dynamic mode. + pub fn serve(&self, request: &Request<Vec<u8>>) -> Served { + let mode = self.mode(); + let Some(rel) = request_rel_path(request.uri()) else { + return Served::error(StatusCode::BAD_REQUEST, "not a document path", mode); + }; + let (canonical, rel, mut file, metadata) = match self.open_confined(&rel) { + Ok(opened) => opened, + Err(status) => { + return Served::error(status, status.canonical_reason().unwrap_or("error"), mode) + } + }; + if metadata.len() > MAX_BODY_BYTES { + return Served::error( + StatusCode::PAYLOAD_TOO_LARGE, + "file too large for a document preview", + mode, + ); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + if file.read_to_end(&mut bytes).is_err() { + return Served::error(StatusCode::INTERNAL_SERVER_ERROR, "cannot read file", mode); + } + drop(file); + let rel_display = rel.to_string_lossy().replace('\\', "/"); + // Dynamic mode: the file must be the one that was approved. Checked + // and recorded under the lock, so two requests for a changed file + // cannot both pass by racing the pin. + if mode == DocMode::Dynamic { + if let Err(reset) = self.verify_approved(&rel_display, &canonical, &metadata, &bytes) { + let mut inner = self.lock(); + inner.approval = None; + inner.reset = Some(reset.clone()); + let mut served = Served::error( + StatusCode::FORBIDDEN, + "file changed after scripts were enabled; the document is back in safe mode", + DocMode::Safe, + ); + served.reset = Some(reset); + return served; + } + } + let content_type = content_type(&canonical); + let total = bytes.len() as u64; + let mut builder = response_builder(mode).header(header::CONTENT_TYPE, content_type); + let range = request + .headers() + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .map(|v| parse_range(v, total)); + let body = match range { + Some(Some((start, end))) => { + let end = end.min(start + MAX_RANGE_BYTES - 1).min(total - 1); + builder = builder.status(StatusCode::PARTIAL_CONTENT).header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ); + bytes[start as usize..=end as usize].to_vec() + } + Some(None) => { + return Served { + response: response_builder(mode) + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Vec::new()) + .expect("static response"), + reset: None, + }; + } + None => bytes, + }; + Served { + response: builder + .header(header::CONTENT_LENGTH, body.len()) + .body(body) + .expect("static response"), + reset: None, + } + } + + /// Resolve `rel` under the root, confined: canonicalized (so a symlink + /// cannot lead outside — a linked folder of the workspace is inside), + /// a directory answered by its `index.html`, and the final component + /// opened without following a symlink swapped in after the check. + fn open_confined(&self, rel: &Path) -> Result<(PathBuf, PathBuf, File, Metadata), StatusCode> { + let mut rel = rel.to_path_buf(); + let mut canonical = + std::fs::canonicalize(self.root.join(&rel)).map_err(|_| StatusCode::NOT_FOUND)?; + if canonical.is_dir() { + rel.push("index.html"); + canonical = std::fs::canonicalize(canonical.join("index.html")) + .map_err(|_| StatusCode::NOT_FOUND)?; + } + if !crate::commands::folders::is_within_workspace(&self.root, &canonical) { + return Err(StatusCode::FORBIDDEN); + } + let file = crate::commands::folders::open_no_follow(&canonical) + .map_err(|_| StatusCode::NOT_FOUND)?; + let metadata = file.metadata().map_err(|_| StatusCode::NOT_FOUND)?; + if !metadata.is_file() { + return Err(StatusCode::NOT_FOUND); + } + Ok((canonical, rel, file, metadata)) + } + + /// Dynamic mode's check: the file's timestamps predate the approval, and + /// its content is what was served since (pinned on first serve). + fn verify_approved( + &self, + rel_display: &str, + canonical: &Path, + metadata: &Metadata, + bytes: &[u8], + ) -> Result<(), DocReset> { + let mut inner = self.lock(); + let Some(approval) = inner.approval.as_mut() else { + // Switched to safe mode while this request was in flight: serve + // it as safe mode would (the CSP already went out with the + // document; the next load is a safe one). + return Ok(()); + }; + if newest_change(metadata) > approval.approved_at { + return Err(DocReset { + path: rel_display.to_string(), + reason: DocResetReason::Newer, + }); + } + let digest: [u8; 32] = Sha256::digest(bytes).into(); + match approval.pins.get(canonical) { + Some(pinned) if *pinned != digest => Err(DocReset { + path: rel_display.to_string(), + reason: DocResetReason::Changed, + }), + Some(_) => Ok(()), + None => { + approval.pins.insert(canonical.to_path_buf(), digest); + Ok(()) + } + } + } +} + +/// The last time the file changed by any account the filesystem keeps: its +/// modification time and, where there is one, its status-change time (a +/// rename into place bumps the latter and not the former). +fn newest_change(metadata: &Metadata) -> SystemTime { + let modified = metadata.modified().unwrap_or(UNIX_EPOCH); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let ctime = UNIX_EPOCH + + Duration::new( + metadata.ctime().max(0) as u64, + metadata.ctime_nsec().clamp(0, 999_999_999) as u32, + ); + modified.max(ctime) + } + #[cfg(not(unix))] + { + modified + } +} + +/// Response to a request, plus whether answering it ended dynamic mode. +pub struct Served { + pub response: Response<Vec<u8>>, + pub reset: Option<DocReset>, +} + +impl Served { + fn error(status: StatusCode, message: &str, mode: DocMode) -> Self { + let body = message.as_bytes().to_vec(); + Served { + response: response_builder(mode) + .status(status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .header(header::CONTENT_LENGTH, body.len()) + .body(body) + .expect("static response"), + reset: None, + } + } +} + +/// The answer to a request from a webview that is not the grant's own. The +/// handler is per webview already; this is the belt to that suspender. +pub fn forbidden() -> Response<Vec<u8>> { + Served::error(StatusCode::FORBIDDEN, "not this document's webview", DocMode::Safe).response +} + +// --------------------------------------------------------------------------- +// Requests and responses +// --------------------------------------------------------------------------- + +/// The document URL for a path relative to the root; each segment +/// percent-encoded by the URL parser. +pub fn document_url(rel: &Path) -> String { + let mut url = Url::parse(&format!("{DOC_SCHEME}://{DOC_HOST}/")).expect("static url"); + { + let mut segments = url.path_segments_mut().expect("url has a host"); + for component in rel.components() { + segments.push(&component.as_os_str().to_string_lossy()); + } + } + url.to_string() +} + +/// The path a request asks for, relative to the root, or `None` for a path +/// no document can have: a `.`/`..` segment, a separator inside a segment, +/// a NUL, or bytes that are not UTF-8. The host part is ignored (a Windows +/// guest sees a mapped host). +fn request_rel_path(uri: &Uri) -> Option<PathBuf> { + let mut rel = PathBuf::new(); + for segment in uri.path().split('/') { + if segment.is_empty() { + continue; + } + let decoded = percent_encoding::percent_decode_str(segment) + .decode_utf8() + .ok()?; + if decoded == "." + || decoded == ".." + || decoded.contains('\0') + || decoded.contains('/') + || decoded.contains('\\') + { + return None; + } + rel.push(&*decoded); + } + Some(rel) +} + +/// `bytes=a-b`, `bytes=a-` or `bytes=-n` (one range). `None` = not a range +/// this understands, answer the whole file; `Some(None)` = unsatisfiable. +fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> { + let spec = header.trim().strip_prefix("bytes=")?; + if spec.contains(',') || total == 0 { + return None; + } + let (start, end) = spec.split_once('-')?; + let (start, end) = match (start.trim(), end.trim()) { + ("", suffix) => { + let n: u64 = suffix.parse().ok()?; + if n == 0 { + return None; + } + (total.saturating_sub(n), total - 1) + } + (start, "") => (start.parse().ok()?, total - 1), + (start, end) => (start.parse().ok()?, end.parse().ok()?), + }; + if start > end || start >= total { + return None; + } + Some((start, end.min(total - 1))) +} + +/// Safe mode: no script, no connection, resources from the root only. The +/// inline preview's strict policy with real URLs instead of `data:`. +const CSP_SAFE: &str = "default-src 'none'; style-src 'self' 'unsafe-inline' data:; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; script-src 'none'; connect-src 'none'; frame-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'"; + +/// Dynamic mode: the document's own scripts run and may fetch from the root; +/// nothing points outside it. Inline scripts and `eval` are allowed because a +/// generated report is exactly the kind of file that has them, and they add +/// no reach: the only endpoint remains the root. +const CSP_DYNAMIC: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' data:; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' data: blob:; frame-src 'self' data: blob:; worker-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'"; + +pub fn csp_for(mode: DocMode) -> &'static str { + match mode { + DocMode::Safe => CSP_SAFE, + DocMode::Dynamic => CSP_DYNAMIC, + } +} + +fn response_builder(mode: DocMode) -> http::response::Builder { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_SECURITY_POLICY, csp_for(mode)) + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::REFERRER_POLICY, "no-referrer") + .header("X-DNS-Prefetch-Control", "off") + .header(header::CACHE_CONTROL, "no-store") + .header(header::ACCEPT_RANGES, "bytes") +} + +/// Content type by extension. Unknown types are `application/octet-stream`, +/// which with `nosniff` the engine neither renders nor runs. +pub fn content_type(path: &Path) -> &'static str { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "html" | "htm" | "xhtml" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" | "cjs" => "text/javascript; charset=utf-8", + "json" | "map" => "application/json; charset=utf-8", + "webmanifest" => "application/manifest+json; charset=utf-8", + "xml" | "xsl" => "application/xml; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "mp4" | "m4v" => "video/mp4", + "webm" => "video/webm", + "ogv" | "ogg" => "video/ogg", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "flac" => "audio/flac", + "txt" | "md" | "markdown" | "log" | "csv" => "text/plain; charset=utf-8", + "pdf" => "application/pdf", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + +// --------------------------------------------------------------------------- +// Registry of grants +// --------------------------------------------------------------------------- + +/// Every document the session has shown, by (root, entry), and which guest +/// tab currently shows which. A grant outlives its guest webview on purpose: +/// the guest is torn down whenever the file leaves the screen, and the mode +/// the user chose — with the approval time it was chosen at — must not be. +#[derive(Default)] +pub struct DocGuests { + grants: Mutex<HashMap<(PathBuf, PathBuf), Arc<DocGrant>>>, + by_tab: Mutex<HashMap<String, Arc<DocGrant>>>, +} + +impl DocGuests { + /// The grant for a document, created on first sight. + pub fn grant_for(&self, root: PathBuf, entry: PathBuf) -> Result<Arc<DocGrant>, String> { + let mut grants = self.grants.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(grant) = grants.get(&(root.clone(), entry.clone())) { + return Ok(grant.clone()); + } + let grant = Arc::new(DocGrant::new(root.clone(), entry.clone())?); + grants.insert((root, entry), grant.clone()); + Ok(grant) + } + + pub fn bind(&self, tab_id: &str, grant: Arc<DocGrant>) { + self.by_tab + .lock() + .unwrap_or_else(|p| p.into_inner()) + .insert(tab_id.to_string(), grant); + } + + pub fn unbind(&self, tab_id: &str) { + self.by_tab + .lock() + .unwrap_or_else(|p| p.into_inner()) + .remove(tab_id); + } + + pub fn for_tab(&self, tab_id: &str) -> Option<Arc<DocGrant>> { + self.by_tab + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(tab_id) + .cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write(dir: &Path, rel: &str, bytes: &[u8]) -> PathBuf { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut file = File::create(&path).unwrap(); + file.write_all(bytes).unwrap(); + path + } + + fn get(grant: &DocGrant, path: &str) -> Served { + let request = Request::builder() + .uri(format!("{DOC_SCHEME}://{DOC_HOST}{path}")) + .body(Vec::new()) + .unwrap(); + grant.serve(&request) + } + + fn grant_in(dir: &Path) -> DocGrant { + let entry = write(dir, "site/index.html", b"<!doctype html><script src=app.js></script>"); + write(dir, "site/app.js", b"console.log(1)"); + write(dir, "site/img/a.png", &[0x89, b'P', b'N', b'G']); + write(dir, "secret.txt", b"outside"); + let root = std::fs::canonicalize(dir.join("site")).unwrap(); + let entry = std::fs::canonicalize(entry).unwrap(); + DocGrant::new(root, entry).unwrap() + } + + fn csp(served: &Served) -> String { + served.response.headers()[header::CONTENT_SECURITY_POLICY] + .to_str() + .unwrap() + .to_string() + } + + #[test] + fn serves_files_under_the_root_with_types_and_fences() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + let page = get(&grant, "/index.html"); + assert_eq!(page.response.status(), StatusCode::OK); + assert_eq!(page.response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8"); + assert!(csp(&page).contains("script-src 'none'")); + assert_eq!(page.response.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert_eq!(page.response.headers()[header::REFERRER_POLICY], "no-referrer"); + assert_eq!(page.response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(page.response.body(), b"<!doctype html><script src=app.js></script>"); + // A directory answers with its index; the root itself too. + assert_eq!(get(&grant, "/").response.status(), StatusCode::OK); + assert_eq!(get(&grant, "/img/a.png").response.headers()[header::CONTENT_TYPE], "image/png"); + assert_eq!(get(&grant, "/img/").response.status(), StatusCode::NOT_FOUND); + assert_eq!(get(&grant, "/missing.css").response.status(), StatusCode::NOT_FOUND); + // Percent-encoded segments decode; traversal and separators do not. + assert_eq!(get(&grant, "/img/%61.png").response.status(), StatusCode::OK); + assert_eq!(get(&grant, "/../secret.txt").response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get(&grant, "/%2e%2e/secret.txt").response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get(&grant, "/img%2F..%2F..%2Fsecret.txt").response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get(&grant, "/a%00.html").response.status(), StatusCode::BAD_REQUEST); + } + + #[cfg(unix)] + #[test] + fn a_symlink_out_of_the_root_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + std::os::unix::fs::symlink(dir.path().join("secret.txt"), dir.path().join("site/leak.txt")) + .unwrap(); + assert_eq!(get(&grant, "/leak.txt").response.status(), StatusCode::FORBIDDEN); + // A symlink to a sibling inside the root is fine. + std::os::unix::fs::symlink(dir.path().join("site/app.js"), dir.path().join("site/alias.js")) + .unwrap(); + assert_eq!(get(&grant, "/alias.js").response.status(), StatusCode::OK); + } + + #[test] + fn dynamic_mode_serves_approved_files_and_resets_on_change() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + // Files written just now must count as approved: their timestamps + // are not newer than an approval taken after them. + grant.set_mode(DocMode::Dynamic); + assert_eq!(grant.mode(), DocMode::Dynamic); + let page = get(&grant, "/index.html"); + assert_eq!(page.response.status(), StatusCode::OK); + assert!(csp(&page).contains("script-src 'self' 'unsafe-inline'")); + assert!(csp(&page).contains("connect-src 'self'")); + assert_eq!(get(&grant, "/app.js").response.status(), StatusCode::OK); + assert!(page.reset.is_none()); + + // The script is rewritten after the approval: refused, and the guest + // is back in safe mode with the file named. + std::thread::sleep(Duration::from_millis(20)); + write(dir.path(), "site/app.js", b"exfiltrate()"); + let served = get(&grant, "/app.js"); + assert_eq!(served.response.status(), StatusCode::FORBIDDEN); + assert_eq!( + served.reset, + Some(DocReset { + path: "app.js".into(), + reason: DocResetReason::Newer + }) + ); + assert_eq!(grant.mode(), DocMode::Safe); + assert_eq!(grant.state("t").reset.as_ref().map(|r| r.path.as_str()), Some("app.js")); + // Safe mode serves it (no script runs under the safe CSP anyway). + let again = get(&grant, "/app.js"); + assert_eq!(again.response.status(), StatusCode::OK); + assert!(csp(&again).contains("script-src 'none'")); + // Re-approving clears the reset and starts afresh. + grant.set_mode(DocMode::Dynamic); + assert!(grant.state("t").reset.is_none()); + assert_eq!(get(&grant, "/app.js").response.status(), StatusCode::OK); + } + + #[test] + fn dynamic_mode_pins_what_it_served() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + grant.set_mode(DocMode::Dynamic); + assert_eq!(get(&grant, "/app.js").response.status(), StatusCode::OK); + // Content swapped with the timestamps put back: the pin catches it. + let path = dir.path().join("site/app.js"); + let before = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::fs::write(&path, b"other()").unwrap(); + let file = File::options().write(true).open(&path).unwrap(); + file.set_modified(before - Duration::from_secs(5)).unwrap(); + drop(file); + let served = get(&grant, "/app.js"); + // Either the status-change time (unix) or the pinned hash refuses it. + assert_eq!(served.response.status(), StatusCode::FORBIDDEN); + assert!(served.reset.is_some()); + assert_eq!(grant.mode(), DocMode::Safe); + } + + #[test] + fn ranges_are_answered_in_bounded_pieces() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + write(dir.path(), "site/clip.mp4", &[0u8; 100]); + let request = Request::builder() + .uri(format!("{DOC_SCHEME}://{DOC_HOST}/clip.mp4")) + .header(header::RANGE, "bytes=10-19") + .body(Vec::new()) + .unwrap(); + let served = grant.serve(&request); + assert_eq!(served.response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(served.response.headers()[header::CONTENT_RANGE], "bytes 10-19/100"); + assert_eq!(served.response.body().len(), 10); + assert_eq!(parse_range("bytes=90-", 100), Some((90, 99))); + assert_eq!(parse_range("bytes=-10", 100), Some((90, 99))); + assert_eq!(parse_range("bytes=0-1000", 100), Some((0, 99))); + assert_eq!(parse_range("bytes=100-", 100), None); + assert_eq!(parse_range("bytes=5-2", 100), None); + assert_eq!(parse_range("bytes=0-1,3-4", 100), None); + assert_eq!(parse_range("items=0-1", 100), None); + } + + #[test] + fn document_urls_and_navigation_policy() { + assert_eq!(document_url(Path::new("a b/c#d.html")), "codeg-doc://doc/a%20b/c%23d.html"); + assert_eq!(document_url(Path::new("index.html")), "codeg-doc://doc/index.html"); + let doc = Url::parse("codeg-doc://doc/other.html").unwrap(); + assert!(is_document_url(&doc)); + assert!(is_document_url(&Url::parse("https://codeg-doc.doc/x").unwrap())); + assert!(!is_document_url(&Url::parse("https://example.com/").unwrap())); + assert_eq!(guest_navigation(&doc, true), GuestNavigation::Allow); + assert_eq!( + guest_navigation(&Url::parse("https://example.com/").unwrap(), true), + GuestNavigation::External + ); + assert_eq!( + guest_navigation(&Url::parse("mailto:a@b.c").unwrap(), true), + GuestNavigation::Scheme + ); + assert_eq!( + guest_navigation(&Url::parse("file:///etc/hosts").unwrap(), true), + GuestNavigation::Scheme + ); + assert_eq!( + guest_navigation(&Url::parse("about:blank").unwrap(), true), + GuestNavigation::Allow + ); + // Opaque-origin content in the document's own frames only. + assert_eq!( + guest_navigation(&Url::parse("about:srcdoc").unwrap(), false), + GuestNavigation::Allow + ); + assert_eq!( + guest_navigation(&Url::parse("about:srcdoc").unwrap(), true), + GuestNavigation::Scheme + ); + assert_eq!( + guest_navigation(&Url::parse("data:text/html,hi").unwrap(), false), + GuestNavigation::Allow + ); + assert_eq!( + guest_navigation(&Url::parse("data:text/html,hi").unwrap(), true), + GuestNavigation::Scheme + ); + } + + #[test] + fn resolve_document_confines_to_the_root_or_the_file_directory() { + let dir = tempfile::tempdir().unwrap(); + let entry = write(dir.path(), "ws/docs/report.html", b"<p>hi</p>"); + let ws = std::fs::canonicalize(dir.path().join("ws")).unwrap(); + let entry_c = std::fs::canonicalize(&entry).unwrap(); + let (root, resolved) = + resolve_document(entry.to_str().unwrap(), Some(ws.to_str().unwrap())).unwrap(); + assert_eq!(root, ws); + assert_eq!(resolved, entry_c); + // No root, or a root that does not contain the file: its own folder. + let (root, _) = resolve_document(entry.to_str().unwrap(), None).unwrap(); + assert_eq!(root, entry_c.parent().unwrap()); + let elsewhere = dir.path().join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let (root, _) = + resolve_document(entry.to_str().unwrap(), Some(elsewhere.to_str().unwrap())).unwrap(); + assert_eq!(root, entry_c.parent().unwrap()); + assert!(resolve_document("relative/path.html", None).is_err()); + assert!(resolve_document(ws.to_str().unwrap(), None).is_err()); + let grant = DocGrant::new(ws.clone(), entry_c).unwrap(); + assert_eq!(grant.document_url(), "codeg-doc://doc/docs/report.html"); + let state = grant.state("t1"); + assert_eq!(state.mode, DocMode::Safe); + assert_eq!(state.root, ws.to_string_lossy()); + assert_eq!(serde_json::to_value(&state).unwrap()["mode"], "safe"); + } + + #[test] + fn grants_are_shared_per_document_and_bound_per_tab() { + let dir = tempfile::tempdir().unwrap(); + let entry = write(dir.path(), "a.html", b"x"); + let root = std::fs::canonicalize(dir.path()).unwrap(); + let entry = std::fs::canonicalize(entry).unwrap(); + let guests = DocGuests::default(); + let first = guests.grant_for(root.clone(), entry.clone()).unwrap(); + first.set_mode(DocMode::Dynamic); + let second = guests.grant_for(root, entry).unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(second.mode(), DocMode::Dynamic); + guests.bind("t1", first.clone()); + assert!(guests.for_tab("t1").is_some()); + guests.unbind("t1"); + assert!(guests.for_tab("t1").is_none()); + assert_eq!(doc_label("t1"), "codeg-doc-t1"); + } + + #[test] + fn content_types_by_extension() { + assert_eq!(content_type(Path::new("A.HTML")), "text/html; charset=utf-8"); + assert_eq!(content_type(Path::new("x.mjs")), "text/javascript; charset=utf-8"); + assert_eq!(content_type(Path::new("x.woff2")), "font/woff2"); + assert_eq!(content_type(Path::new("x.bin")), "application/octet-stream"); + assert_eq!(content_type(Path::new("noext")), "application/octet-stream"); + } +} diff --git a/src-tauri/src/browser/events.rs b/src-tauri/src/browser/events.rs index b3f45f5278..457b236129 100644 --- a/src-tauri/src/browser/events.rs +++ b/src-tauri/src/browser/events.rs @@ -5,12 +5,13 @@ use tauri::AppHandle; use crate::web::event_bridge::{emit_event, EventEmitter}; +use super::doc_guest::DocGuestState; use super::downloads::{BrowserDownload, DOWNLOAD_EVENT}; use super::types::{ BrowserClosedPayload, BrowserNavigationBlockedPayload, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserShortcutPayload, BrowserTabState, NavigationBlockReason, - CLOSED_EVENT, NAVIGATION_BLOCKED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, SHORTCUT_EVENT, - STATE_EVENT, + CLOSED_EVENT, DOC_STATE_EVENT, NAVIGATION_BLOCKED_EVENT, OPEN_REQUEST_EVENT, POPUP_EVENT, + SHORTCUT_EVENT, STATE_EVENT, }; pub fn emit_state(app: &AppHandle, state: &BrowserTabState) { @@ -47,6 +48,10 @@ pub fn emit_shortcut(app: &AppHandle, tab_id: &str, shortcut: &str) { ); } +pub fn emit_doc_state(app: &AppHandle, state: &DocGuestState) { + emit_event(&EventEmitter::Tauri(app.clone()), DOC_STATE_EVENT, state); +} + pub fn emit_download(app: &AppHandle, download: &BrowserDownload) { emit_event(&EventEmitter::Tauri(app.clone()), DOWNLOAD_EVENT, download); } diff --git a/src-tauri/src/browser/hooks.rs b/src-tauri/src/browser/hooks.rs index 02eae07770..3528c35b47 100644 --- a/src-tauri/src/browser/hooks.rs +++ b/src-tauri/src/browser/hooks.rs @@ -413,12 +413,13 @@ pub fn title_changed(app: &AppHandle, tab_id: &str, title: String) { #[cfg(test)] mod tests { use super::*; - use crate::browser::types::{BrowserTabState, ChannelKind, SurfaceKind}; + use crate::browser::types::{BrowserTabState, ChannelKind, SurfaceKind, TabKind}; fn state(url: &str, requested: &str) -> BrowserTabState { BrowserTabState { tab_id: "t1".into(), owner_window: "main".into(), + kind: TabKind::Page, surface: SurfaceKind::Child, channel: ChannelKind::Native, url: url.into(), diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index 5cab2aad14..d400889942 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -13,6 +13,7 @@ //! Module map: //! - `types` — wire types shared with `src/lib/browser/types.ts` //! - `policy` — pure decisions (scheme allow-list, …) +//! - `doc_guest` — the `codeg-doc:` guest that shows a local HTML file //! - `profile` — the tabs' own data store / directory and their proxy //! - `downloads` — destination policy and records for page downloads //! - `registry` — tab id → surface + last known state @@ -26,6 +27,7 @@ //! `browser-smoke`, never in a release build) pub mod channel; +pub mod doc_guest; pub mod downloads; pub mod events; pub mod hooks; @@ -46,6 +48,7 @@ pub mod shim; #[cfg(feature = "browser-smoke")] pub mod smoke; +pub use doc_guest::DocGuests; pub use downloads::BrowserDownloads; pub use registry::BrowserRegistry; diff --git a/src-tauri/src/browser/shim/macos.rs b/src-tauri/src/browser/shim/macos.rs index bee2bf3e03..dbb7cc9c1b 100644 --- a/src-tauri/src/browser/shim/macos.rs +++ b/src-tauri/src/browser/shim/macos.rs @@ -726,6 +726,19 @@ pub fn profile_configuration(mtm: MainThreadMarker) -> Retained<WKWebViewConfigu } } +/// A configuration for a document guest: a data store that lives in memory +/// and dies with the webview, so nothing a document stores outlives it or is +/// shared with browser tabs or the app. Nothing here depends on the macOS +/// version — non-persistent stores predate identifier-based ones. +pub fn document_configuration(mtm: MainThreadMarker) -> Retained<WKWebViewConfiguration> { + // SAFETY: main thread; both objects are live. + unsafe { + let configuration = WKWebViewConfiguration::new(mtm); + configuration.setWebsiteDataStore(&WKWebsiteDataStore::nonPersistentDataStore(mtm)); + configuration + } +} + /// Create the profile's store if needed and point it at `proxy` (or at no /// proxy). Open tabs use the new value for their next connections; setting the /// same value again does nothing, so callers can be liberal. diff --git a/src-tauri/src/browser/smoke.rs b/src-tauri/src/browser/smoke.rs index 4f64498776..f3270e7e1a 100644 --- a/src-tauri/src/browser/smoke.rs +++ b/src-tauri/src/browser/smoke.rs @@ -15,6 +15,7 @@ use std::time::{Duration, Instant}; use serde_json::{json, Value}; use tauri::{AppHandle, Manager}; +use crate::browser::doc_guest::{DocGuests, DocMode}; use crate::browser::registry::BrowserRegistry; use crate::browser::types::{Bounds, SurfaceChoice}; use crate::commands::browser as browser_commands; @@ -241,6 +242,51 @@ async fn execute(app: &AppHandle, cmd: &Value) -> Result<Value, String> { .map_err(err_string)?; Ok(json!(state)) } + "browser_doc_open" => { + let owner = owner()?; + let guests = app.state::<DocGuests>(); + let result = browser_commands::doc_open_core( + app, + &owner, + ®istry, + &guests, + browser_commands::DocOpenParams { + tab_id: str_arg(cmd, "tab_id")?, + path: str_arg(cmd, "path")?, + root: cmd.get("root").and_then(Value::as_str).map(str::to_string), + bounds: bounds_arg(cmd)?, + background: cmd + .get("background") + .and_then(Value::as_bool) + .unwrap_or(false), + devtools: cmd.get("devtools").and_then(Value::as_bool).unwrap_or(false), + }, + ) + .map_err(err_string)?; + Ok(json!(result)) + } + "browser_doc_mode" => { + let guests = app.state::<DocGuests>(); + let mode = match cmd.get("mode").and_then(Value::as_str) { + Some("dynamic") => DocMode::Dynamic, + _ => DocMode::Safe, + }; + let state = browser_commands::doc_set_mode_core( + app, + ®istry, + &guests, + &str_arg(cmd, "tab_id")?, + mode, + ) + .map_err(err_string)?; + Ok(json!(state)) + } + "browser_doc_state" => { + let guests = app.state::<DocGuests>(); + let state = browser_commands::doc_state_core(&guests, &str_arg(cmd, "tab_id")?) + .map_err(err_string)?; + Ok(json!(state)) + } "browser_set_bounds" => { browser_commands::set_bounds_core(®istry, &str_arg(cmd, "tab_id")?, bounds_arg(cmd)?) .map_err(err_string)?; diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index 892453afc7..adaef07d9a 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -34,6 +34,7 @@ use tauri_runtime_wry::wry::{ }; use super::channel::{self, MessageSink}; +use super::doc_guest::{self, DocGrant, GuestNavigation}; #[cfg(target_os = "windows")] use super::profile; use super::events; @@ -43,7 +44,7 @@ use super::registry::{BrowserRegistry, BrowserTab}; use super::surface::BrowserSurface; use super::types::{ Bounds, BrowserOpenRequestPayload, BrowserPopupPayload, BrowserTabState, ChannelKind, - NavigationBlockReason, PopupPresentation, SurfaceKind, + NavigationBlockReason, PopupPresentation, SurfaceKind, TabKind, }; #[cfg(target_os = "macos")] use super::shim::macos as shim; @@ -511,6 +512,16 @@ type OpenerConfiguration = objc2::rc::Retained<objc2_web_kit::WKWebViewConfigura #[cfg(not(target_os = "macos"))] type OpenerConfiguration = (); +/// What a child webview is for. A page gets the browser's policy (scheme +/// lists, site rules, popups, downloads); a document guest gets the guest's +/// (its own scheme, nothing else, no popups, no downloads) and the handler +/// that serves its files. +#[derive(Clone)] +pub enum ChildKind { + Page, + Document(Arc<DocGrant>), +} + /// Main thread only. Builds the child webview at `bounds` with every hook /// attached and **no URL**: a regular tab is navigated by the caller once the /// page channel is installed, a popup is navigated by the engine itself. @@ -532,6 +543,7 @@ fn build_child( visible: bool, devtools: bool, configuration: Option<OpenerConfiguration>, + kind: &ChildKind, ) -> Result<wry::WebView, String> { #[cfg(target_os = "windows")] { @@ -550,6 +562,7 @@ fn build_child( visible, devtools, configuration, + kind, )? .build_as_child(owner) .map_err(|e| e.to_string()) @@ -567,12 +580,68 @@ fn build_child( visible, devtools, configuration, + kind, )? .build_as_child(owner) .map_err(|e| e.to_string()) } } +/// The `codeg-doc:` handler of one document guest. Registered on the guest's +/// builder alone, bound to its grant, and checked against the webview it is +/// called for; the file work happens off the main thread (the engine calls +/// here on it), and a request that ends dynamic mode reports the new state. +fn document_protocol( + app: &AppHandle, + tab_id: &str, + label: &str, + grant: Arc<DocGrant>, +) -> impl Fn(wry::WebViewId<'_>, wry::http::Request<Vec<u8>>, wry::RequestAsyncResponder) + 'static { + let app = app.clone(); + let tab_id = tab_id.to_string(); + let label = label.to_string(); + move |webview_id, request, responder| { + if webview_id != label { + tracing::warn!( + "[browser] document request from webview {webview_id:?} refused (handler belongs to {label})" + ); + responder.respond(doc_guest::forbidden()); + return; + } + let grant = grant.clone(); + let app = app.clone(); + let tab_id = tab_id.clone(); + tauri::async_runtime::spawn_blocking(move || { + let served = grant.serve(&request); + let reset = served.reset.is_some(); + if let Some(reset) = &served.reset { + tracing::info!( + "[browser] document {tab_id}: {} {:?} after scripts were enabled; back in safe mode", + reset.path, + reset.reason + ); + events::emit_doc_state(&app, &grant.state(&tab_id)); + } + responder.respond(served.response); + // The document that is loading was served under the dynamic + // policy; reload it so the safe one applies to the whole page, + // not only to the file that was refused. Done here, not left to + // the frontend, so the fence holds with nobody watching. + if reset { + if let Some(registry) = app.try_state::<BrowserRegistry>() { + if let Some(surface) = registry.surface(&tab_id) { + if let Err(err) = surface.reload() { + tracing::warn!("[browser] document {tab_id}: reload after reset failed: {err}"); + } else { + hooks::begin_load(&app, &tab_id); + } + } + } + } + }); + } +} + #[allow(clippy::too_many_arguments)] fn configure_child<'a>( builder: WebViewBuilder<'a>, @@ -584,10 +653,12 @@ fn configure_child<'a>( visible: bool, devtools: bool, configuration: Option<OpenerConfiguration>, + kind: &ChildKind, ) -> Result<WebViewBuilder<'a>, String> { let nav_id = tab_id.to_string(); let nav_app = app.clone(); let nav_owner = owner.label().to_string(); + let nav_kind = kind.clone(); let mut builder = builder .with_id(label) .with_bounds(rect(bounds)) @@ -603,6 +674,26 @@ fn configure_child<'a>( // The engine asks about every frame's navigation; only the // top-level one gets the strict list and a notice when refused. let main_frame = current_navigation_is_main_frame(); + // A document guest: its own documents and nothing with a scheme + // of its own. A web address is reported so the user can open it + // in a tab; the guest itself never leaves its root. + if let ChildKind::Document(_) = &nav_kind { + return match doc_guest::guest_navigation(&parsed, main_frame) { + GuestNavigation::Allow => true, + GuestNavigation::External => { + if main_frame { + hooks::navigation_blocked(&nav_app, &nav_id, &url, NavigationBlockReason::External); + } + false + } + GuestNavigation::Scheme => { + if main_frame { + hooks::navigation_blocked(&nav_app, &nav_id, &url, NavigationBlockReason::Scheme); + } + false + } + }; + } let scheme_ok = if main_frame { policy::navigation_allowed(&parsed) } else { @@ -663,34 +754,68 @@ fn configure_child<'a>( let app = app.clone(); let id = tab_id.to_string(); move |title| hooks::title_changed(&app, &id, title) - }) - // The engine transfers the file; the host only decides where it may - // land (`downloads::requested` rewrites the path) and reports it. - .with_download_started_handler({ - let app = app.clone(); - let id = tab_id.to_string(); - move |url, destination| super::downloads::requested(&app, &id, &url, destination) - }) - .with_download_completed_handler({ - let app = app.clone(); - move |url: String, path, success| { - super::downloads::finished(&app, &url, path, success) - } - }) - .with_new_window_req_handler(new_window_handler(app.clone(), owner.clone(), tab_id.to_string())); + }); + builder = match kind { + ChildKind::Page => builder + // The engine transfers the file; the host only decides where it + // may land (`downloads::requested` rewrites the path) and + // reports it. + .with_download_started_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |url, destination| super::downloads::requested(&app, &id, &url, destination) + }) + .with_download_completed_handler({ + let app = app.clone(); + move |url: String, path, success| { + super::downloads::finished(&app, &url, path, success) + } + }) + .with_new_window_req_handler(new_window_handler(app.clone(), owner.clone(), tab_id.to_string())), + ChildKind::Document(grant) => builder + // A document does not download and does not open windows: both + // are refused and reported, and a web address a `window.open` + // names is offered to the user like a link would be. + .with_download_started_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |url, _destination| { + hooks::navigation_blocked(&app, &id, &url, NavigationBlockReason::Download); + false + } + }) + .with_new_window_req_handler({ + let app = app.clone(); + let id = tab_id.to_string(); + move |url, _features| { + let reason = match Url::parse(&url) { + Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => { + NavigationBlockReason::External + } + _ => NavigationBlockReason::Scheme, + }; + hooks::navigation_blocked(&app, &id, &url, reason); + NewWindowResponse::Deny + } + }) + .with_asynchronous_custom_protocol( + doc_guest::DOC_SCHEME.to_string(), + document_protocol(app, tab_id, label, grant.clone()), + ), + }; #[cfg(target_os = "macos")] { use tauri_runtime_wry::wry::WebViewBuilderExtMacos; + let mtm = objc2::MainThreadMarker::new().ok_or("not on the main thread")?; // A popup keeps its opener's configuration (that is what preserves // `window.opener`); a regular tab gets one whose data store is the // browser profile's, so nothing a page stores lands in the app's own - // store and the profile's proxy applies. - let configuration = match configuration { - Some(configuration) => configuration, - None => { - let mtm = objc2::MainThreadMarker::new().ok_or("not on the main thread")?; - super::shim::macos::profile_configuration(mtm) - } + // store and the profile's proxy applies; a document guest gets a + // store that dies with it. + let configuration = match (configuration, kind) { + (Some(configuration), _) => configuration, + (None, ChildKind::Page) => super::shim::macos::profile_configuration(mtm), + (None, ChildKind::Document(_)) => super::shim::macos::document_configuration(mtm), }; builder = builder.with_webview_configuration(configuration); } @@ -704,6 +829,12 @@ fn configure_child<'a>( builder = builder.with_additional_browser_args(profile::windows_browser_args( profile::frozen_proxy().as_ref(), )); + // A document guest keeps nothing: an in-private profile of the same + // environment. (Not exercised yet — `doc_guest::supported()` is false + // here until it is.) + if let ChildKind::Document(_) = kind { + builder = builder.with_incognito(true); + } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] let _ = configuration; @@ -731,7 +862,41 @@ pub fn create( let id = tab_id.to_string(); let label = label.to_string(); run_on_main(&app.clone(), move || -> Result<(), String> { - let webview = build_child(&app, &owner, &id, &label, bounds, !background, devtools, None)?; + let webview = build_child(&app, &owner, &id, &label, bounds, !background, devtools, None, &ChildKind::Page)?; + attach_navigation_delegate(&app, &id, &webview); + SURFACES.with(|s| s.borrow_mut().insert(id, webview)); + Ok(()) + })? + .map_err(ChildError::Op)?; + Ok(handle) +} + +/// Build the child webview for a document guest: the same surface as a tab, +/// with the guest's policy and the handler that serves `grant`'s files. The +/// caller navigates to the document once the page ↔ host channel is in. +#[allow(clippy::too_many_arguments)] +pub fn create_document( + app: &AppHandle, + owner: &WebviewWindow, + tab_id: &str, + label: &str, + bounds: Bounds, + background: bool, + devtools: bool, + grant: Arc<DocGrant>, +) -> Result<ChildHandle, ChildError> { + let handle = ChildHandle { + tab_id: tab_id.to_string(), + label: label.to_string(), + app: app.clone(), + }; + let app = app.clone(); + let owner = owner.clone(); + let id = tab_id.to_string(); + let label = label.to_string(); + run_on_main(&app.clone(), move || -> Result<(), String> { + let kind = ChildKind::Document(grant); + let webview = build_child(&app, &owner, &id, &label, bounds, !background, devtools, None, &kind)?; attach_navigation_delegate(&app, &id, &webview); SURFACES.with(|s| s.borrow_mut().insert(id, webview)); Ok(()) @@ -807,7 +972,7 @@ fn new_window_handler( .update(&opener_tab_id, |tab| (tab.last_bounds, tab.devtools)) .unwrap_or_default(); let configuration = features.opener.target_configuration.clone(); - let webview = match build_child(&app, &owner, &tab_id, &label, bounds, true, devtools, Some(configuration)) { + let webview = match build_child(&app, &owner, &tab_id, &label, bounds, true, devtools, Some(configuration), &ChildKind::Page) { Ok(webview) => webview, Err(err) => { tracing::warn!("[browser] popup webview creation failed: {err}"); @@ -839,6 +1004,7 @@ fn new_window_handler( let state = BrowserTabState { tab_id: tab_id.clone(), owner_window: owner.label().to_string(), + kind: TabKind::Page, surface: SurfaceKind::Child, channel, url: String::new(), diff --git a/src-tauri/src/browser/types.rs b/src-tauri/src/browser/types.rs index 64f84f9412..ab26c7c732 100644 --- a/src-tauri/src/browser/types.rs +++ b/src-tauri/src/browser/types.rs @@ -14,6 +14,16 @@ pub enum SurfaceKind { Window, } +/// What a tab shows: a web page, or a local HTML document served through the +/// `codeg-doc:` guest (see `doc_guest`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TabKind { + #[default] + Page, + Document, +} + /// How the page ↔ host channel was installed for a tab. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -66,6 +76,7 @@ pub struct BrowserTabState { pub tab_id: String, /// Label of the window the tab belongs to (`main`, `remote-workspace-*`). pub owner_window: String, + pub kind: TabKind, pub surface: SurfaceKind, pub channel: ChannelKind, /// Last committed URL. @@ -106,6 +117,9 @@ pub struct BrowserCapabilities { /// The administrator's policy in force (rules shown read-only, and /// whether the browser is enabled at all). pub policy: crate::browser::policy::BrowserPolicyStatus, + /// Local HTML files can be shown through the `codeg-doc:` document guest + /// (an embedded surface with a handler for that scheme; macOS for now). + pub doc_guest: bool, } /// The last frame of a page, handed back by `browser_set_visible` when the @@ -147,12 +161,20 @@ pub const SHORTCUT_EVENT: &str = "browser://shortcut"; /// type is not allowed in a tab, or a site rule blocks the host. The status /// layer tells the user; nothing else happens. pub const NAVIGATION_BLOCKED_EVENT: &str = "browser://navigation-blocked"; +/// The mode and status of a document guest changed (`DocGuestState`): the +/// user switched it, or the guest fell back to safe mode on its own. +pub const DOC_STATE_EVENT: &str = "browser://doc-state"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum NavigationBlockReason { HostRule, Scheme, + /// A document guest pointed at a web address: not loaded in the guest, + /// but the user may open it in a browser tab. + External, + /// A document guest tried to download a file; documents do not download. + Download, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -223,6 +245,7 @@ mod tests { let state = BrowserTabState { tab_id: "t1".into(), owner_window: "main".into(), + kind: TabKind::Page, surface: SurfaceKind::Child, channel: ChannelKind::Native, url: "about:blank".into(), @@ -241,6 +264,7 @@ mod tests { let json = serde_json::to_value(&state).unwrap(); assert_eq!(json["tabId"], "t1"); assert_eq!(json["ownerWindow"], "main"); + assert_eq!(json["kind"], "page"); assert_eq!(json["surface"], "child"); assert_eq!(json["channel"], "native"); assert_eq!(json["requestedUrl"], "https://example.com/"); diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index f630700ee3..6489319b9f 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -10,13 +10,14 @@ use tauri::{AppHandle, Manager, State, WebviewWindow}; use tauri::Url; use crate::app_error::AppCommandError; +use crate::browser::doc_guest::{self, DocGuestState, DocGuests, DocMode}; use crate::browser::downloads::{BrowserDownload, BrowserDownloads}; use crate::browser::policy::{BrowserPolicy, HostRule}; use crate::browser::registry::{BrowserRegistry, BrowserTab}; use crate::browser::surface::BrowserSurface; use crate::browser::types::{ Bounds, BrowserCapabilities, BrowserErrorInfo, BrowserErrorKind, BrowserTabState, ChannelKind, - FrozenFrame, SurfaceChoice, SurfaceKind, + FrozenFrame, SurfaceChoice, SurfaceKind, TabKind, }; use crate::browser::{events, hooks, policy, tab_label}; @@ -74,6 +75,7 @@ pub fn capabilities(policy: &BrowserPolicy) -> BrowserCapabilities { proxy: crate::browser::profile::proxy_status(), downloads_dir: crate::browser::downloads::downloads_dir_display(), policy: policy.status(), + doc_guest: enabled && doc_guest::supported(), } } @@ -213,6 +215,7 @@ pub fn open_tab_core( let state = BrowserTabState { tab_id: params.tab_id.clone(), owner_window: owner.label().to_string(), + kind: TabKind::Page, surface: surface.kind(), channel: ChannelKind::Degraded, url: String::new(), @@ -287,6 +290,183 @@ pub fn open_tab_core( Ok(state) } +pub struct DocOpenParams { + pub tab_id: String, + /// Absolute path of the HTML file. + pub path: String, + /// Directory to confine the document to (the owning workspace folder); + /// the file's own directory when absent or not containing the file. + pub root: Option<String>, + pub bounds: Bounds, + pub background: bool, + pub devtools: bool, +} + +/// What `browser_doc_open` answers: the tab like any other, and the guest's +/// own state (mode, root, URL). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocOpenResult { + pub state: BrowserTabState, + pub doc: DocGuestState, +} + +/// Show a local HTML file through a document guest (see `doc_guest`). The +/// guest is an embedded surface like a tab's, registered under the same +/// registry so bounds, visibility, reload and close work unchanged; the +/// grant — root, entry, mode — is looked up by document, so the file comes +/// back in the mode the user last chose for it this session. +pub fn doc_open_core( + app: &AppHandle, + owner: &WebviewWindow, + registry: &BrowserRegistry, + guests: &DocGuests, + params: DocOpenParams, +) -> Result<DocOpenResult, AppCommandError> { + validate_tab_id(¶ms.tab_id)?; + if registry.contains(¶ms.tab_id) { + return Err(AppCommandError::already_exists(format!( + "browser tab {} is already open", + params.tab_id + ))); + } + if app + .try_state::<BrowserPolicy>() + .is_some_and(|policy| !policy.enabled()) + { + return Err(AppCommandError::invalid_input( + "the built-in browser is disabled by the administrator's policy", + )); + } + if !doc_guest::supported() { + return Err(AppCommandError::invalid_input( + "document guests need the embedded browser surface (macOS for now)", + )); + } + let (root, entry) = doc_guest::resolve_document(¶ms.path, params.root.as_deref()) + .map_err(AppCommandError::invalid_input)?; + let grant = guests + .grant_for(root, entry) + .map_err(AppCommandError::invalid_input)?; + let label = doc_guest::doc_label(¶ms.tab_id); + let surface = { + #[cfg(all(feature = "browser-child", target_os = "macos"))] + { + BrowserSurface::Child( + crate::browser::surface_child::create_document( + app, + owner, + ¶ms.tab_id, + &label, + params.bounds, + params.background, + params.devtools, + grant.clone(), + ) + .map_err(|e| window_err("Failed to create document webview", e))?, + ) + } + #[cfg(not(all(feature = "browser-child", target_os = "macos")))] + { + let _ = (owner, &label); + return Err(AppCommandError::invalid_input( + "document guests need the embedded browser surface (macOS for now)", + )); + } + }; + let url = grant.document_url(); + let state = BrowserTabState { + tab_id: params.tab_id.clone(), + owner_window: owner.label().to_string(), + kind: TabKind::Document, + surface: surface.kind(), + channel: ChannelKind::Degraded, + url: String::new(), + requested_url: url.clone(), + title: String::new(), + favicon: None, + loading: true, + can_go_back: false, + can_go_forward: false, + origin: None, + zoom: 1.0, + error: None, + remote_host: None, + opener_tab_id: None, + }; + if let Err(err) = registry.insert(BrowserTab::new( + state.clone(), + surface.clone(), + params.bounds, + !params.background, + params.devtools, + )) { + let _ = surface.close(); + return Err(err); + } + guests.bind(¶ms.tab_id, grant.clone()); + let mut state = state; + match surface.install_channel() { + Ok(true) => {} + Ok(false) => { + if let Some(next) = + registry.update_state(¶ms.tab_id, |s| s.channel = ChannelKind::Legacy) + { + state = next; + } + } + Err(err) => { + tracing::warn!( + "[browser] document {}: page channel unavailable ({err}); continuing degraded", + params.tab_id + ); + } + } + if params.background { + let _ = surface.hide(); + } + let parsed = Url::parse(&url) + .map_err(|e| AppCommandError::invalid_input(format!("bad document url {url:?}: {e}")))?; + if let Err(err) = surface.navigate(parsed) { + registry.remove(¶ms.tab_id); + guests.unbind(¶ms.tab_id); + let _ = surface.close(); + return Err(window_err("Failed to load the document", err)); + } + hooks::begin_load(app, ¶ms.tab_id); + events::emit_state(app, &state); + let doc = grant.state(¶ms.tab_id); + events::emit_doc_state(app, &doc); + Ok(DocOpenResult { state, doc }) +} + +/// The user's choice of mode for a document. Takes effect on the reload +/// this performs: the CSP travels with the document, and a fresh approval +/// starts counting from now. +pub fn doc_set_mode_core( + app: &AppHandle, + registry: &BrowserRegistry, + guests: &DocGuests, + tab_id: &str, + mode: DocMode, +) -> Result<DocGuestState, AppCommandError> { + let grant = guests + .for_tab(tab_id) + .ok_or_else(|| AppCommandError::not_found(format!("document guest {tab_id} not found")))?; + grant.set_mode(mode); + let doc = grant.state(tab_id); + events::emit_doc_state(app, &doc); + reload_core(app, registry, tab_id)?; + Ok(doc) +} + +pub fn doc_state_core(guests: &DocGuests, tab_id: &str) -> Result<DocGuestState, AppCommandError> { + guests + .for_tab(tab_id) + .map(|grant| grant.state(tab_id)) + .ok_or_else(|| AppCommandError::not_found(format!("document guest {tab_id} not found"))) +} + /// Wipe cookies, caches and every other kind of stored site data. All tabs /// share one persistent store, so this is app-wide; open pages keep running /// (nothing is reloaded, as in a browser). @@ -358,6 +538,9 @@ fn surface_of(registry: &BrowserRegistry, tab_id: &str) -> Result<BrowserSurface pub fn close_core(app: &AppHandle, registry: &BrowserRegistry, tab_id: &str) -> Result<(), AppCommandError> { if let Some(tab) = registry.remove(tab_id) { let _ = tab.surface.close(); + if let Some(guests) = app.try_state::<DocGuests>() { + guests.unbind(tab_id); + } events::emit_closed(app, tab_id, &tab.state.owner_window); } Ok(()) @@ -370,6 +553,9 @@ pub fn close_all_for_owner(app: &AppHandle, owner_window: &str) { if let Some(registry) = app.try_state::<BrowserRegistry>() { for tab in registry.remove_by_owner(owner_window) { let _ = tab.surface.close(); + if let Some(guests) = app.try_state::<DocGuests>() { + guests.unbind(&tab.state.tab_id); + } } } } @@ -516,6 +702,15 @@ pub fn navigate_core( ) -> Result<BrowserTabState, AppCommandError> { let url = parse_web_url(raw_url)?; let surface = surface_of(registry, tab_id)?; + // A document guest shows one file; it is not an address bar. + if registry + .state(tab_id) + .is_some_and(|state| state.kind == TabKind::Document) + { + return Err(AppCommandError::invalid_input( + "a document view cannot be navigated to another address", + )); + } // Refused by a site rule: the block page takes the place of the page, // as in a browser, and nothing is loaded. Whatever was loading before // is stopped and its watcher retired, or its commit or failure would @@ -702,6 +897,55 @@ pub async fn browser_open_tab( ) } +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn browser_doc_open( + app: AppHandle, + window: WebviewWindow, + registry: State<'_, BrowserRegistry>, + guests: State<'_, DocGuests>, + tab_id: String, + path: String, + root: Option<String>, + bounds: Bounds, + background: Option<bool>, + devtools: Option<bool>, +) -> Result<DocOpenResult, AppCommandError> { + doc_open_core( + &app, + &window, + ®istry, + &guests, + DocOpenParams { + tab_id, + path, + root, + bounds, + background: background.unwrap_or(false), + devtools: devtools.unwrap_or(false), + }, + ) +} + +#[tauri::command] +pub async fn browser_doc_set_mode( + app: AppHandle, + registry: State<'_, BrowserRegistry>, + guests: State<'_, DocGuests>, + tab_id: String, + mode: DocMode, +) -> Result<DocGuestState, AppCommandError> { + doc_set_mode_core(&app, ®istry, &guests, &tab_id, mode) +} + +#[tauri::command] +pub async fn browser_doc_state( + guests: State<'_, DocGuests>, + tab_id: String, +) -> Result<DocGuestState, AppCommandError> { + doc_state_core(&guests, &tab_id) +} + #[tauri::command] pub async fn browser_clear_data( app: AppHandle, @@ -873,6 +1117,7 @@ mod tests { assert!(caps.surface.is_some()); assert!(!caps.platform.is_empty()); assert!(caps.policy.enabled); + assert_eq!(caps.doc_guest, doc_guest::supported()); } /// An administrator can turn the feature off: no surface is offered and @@ -887,6 +1132,7 @@ mod tests { let caps = capabilities(&policy); assert!(!caps.available); assert!(caps.surface.is_none()); + assert!(!caps.doc_guest); assert!(!caps.policy.enabled); assert!(caps.reasons.iter().any(|r| r.contains("policy"))); } diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 6b677c8f63..309c6c64c8 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -5132,7 +5132,7 @@ pub async fn read_file_base64( /// path validated by canonicalization cannot be redirected through a symlink /// swapped in afterward. #[cfg(unix)] -fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { +pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .read(true) @@ -5141,7 +5141,7 @@ fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { } #[cfg(windows)] -fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { +pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { use std::os::windows::fs::OpenOptionsExt; // FILE_FLAG_OPEN_REPARSE_POINT opens the reparse point itself instead of // following it, so a symlink/junction swapped in after validation is opened @@ -5155,7 +5155,7 @@ fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { } #[cfg(not(any(unix, windows)))] -fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { +pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> { std::fs::File::open(path) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a219e0e988..1546550868 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -395,6 +395,7 @@ mod tauri_app { .manage(ConnectionManager::new()) .manage(crate::browser::BrowserRegistry::default()) .manage(crate::browser::BrowserDownloads::default()) + .manage(crate::browser::DocGuests::default()) .manage(crate::browser::policy::BrowserPolicy::load()) .manage(TerminalManager::new()) .manage(ChatChannelManager::new()) @@ -1214,6 +1215,9 @@ mod tauri_app { browser_commands::browser_list_downloads, browser_commands::browser_clear_downloads, browser_commands::browser_set_host_rules, + browser_commands::browser_doc_open, + browser_commands::browser_doc_set_mode, + browser_commands::browser_doc_state, conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index 07f2aaea31..fbeb77040c 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -21,6 +21,7 @@ const DESKTOP_WITHOUT_BROWSER = { isolatedStorage: false, proxy: { url: null, applies: "unsupported" as const, reason: null }, downloadsDir: "", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, } diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 79e872ec05..7d008b222d 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) ), @@ -135,6 +136,7 @@ describe("BrowserEventsBridge", () => { expect(mocks.browserClose).toHaveBeenCalledWith("stale-2") expect([...mocks.handlers.keys()].sort()).toEqual([ "browser://closed", + "browser://doc-state", "browser://download", "browser://navigation-blocked", "browser://open-request", @@ -192,6 +194,7 @@ describe("BrowserEventsBridge", () => { source: "modifier-click", activate: false, ownerWindow: "main", + kind: "page", openerTabId: "abc", }) expect(mocks.openBrowserTab).toHaveBeenCalledTimes(2) @@ -203,6 +206,7 @@ describe("BrowserEventsBridge", () => { mocks.handlers.get("browser://state")!({ tabId: "abc", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/", @@ -246,6 +250,7 @@ describe("BrowserEventsBridge", () => { setBrowserTabState({ tabId: "abc-p1", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "", @@ -264,6 +269,7 @@ describe("BrowserEventsBridge", () => { mocks.handlers.get("browser://closed")!({ tabId: "abc-p1", ownerWindow: "main", + kind: "page", }) expect(getBrowserTabState("browser:abc-p1")).toBeNull() expect(mocks.closeFileTab).toHaveBeenCalledWith("browser:abc-p1") @@ -290,6 +296,7 @@ describe("BrowserEventsBridge", () => { isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) render(<BrowserEventsBridge />) @@ -367,6 +374,7 @@ describe("BrowserEventsBridge", () => { isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) await Promise.resolve() diff --git a/src/components/browser/browser-events-bridge.tsx b/src/components/browser/browser-events-bridge.tsx index 68f4787a49..a25a22a40e 100644 --- a/src/components/browser/browser-events-bridge.tsx +++ b/src/components/browser/browser-events-bridge.tsx @@ -24,9 +24,11 @@ import { requestBrowserFind, setBrowserTabNotice, setBrowserTabState, + setDocGuestState, } from "@/lib/browser/browser-tab-store" import { BROWSER_CLOSED_EVENT, + BROWSER_DOC_STATE_EVENT, BROWSER_DOWNLOAD_EVENT, BROWSER_NAVIGATION_BLOCKED_EVENT, BROWSER_OPEN_REQUEST_EVENT, @@ -40,6 +42,7 @@ import { type BrowserPopupPayload, type BrowserShortcutPayload, type BrowserTabState, + type DocGuestState, } from "@/lib/browser/types" import { getTransport } from "@/lib/transport" import { getCurrentWindowLabel } from "@/lib/browser/window-label" @@ -59,6 +62,8 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" * - `browser://shortcut` → a browser shortcut the page had focus for (⌘F) * - `browser://navigation-blocked` → a notice on the tab whose navigation * policy refused + * - `browser://doc-state` → the mode of a document guest (the file column's + * HTML preview), including a fall-back to safe mode * * It also carries the user's site rules the other way: the backend enforces * `block` on every navigation a tab attempts, and learns the table from here @@ -184,6 +189,9 @@ export function BrowserEventsBridge() { }) } ), + transport.subscribe<DocGuestState>(BROWSER_DOC_STATE_EVENT, (doc) => { + setDocGuestState(doc) + }), transport.subscribe<BrowserOpenRequestPayload>( BROWSER_OPEN_REQUEST_EVENT, (request) => { diff --git a/src/components/browser/browser-status-layer.tsx b/src/components/browser/browser-status-layer.tsx index f454ddbe80..ba36321a23 100644 --- a/src/components/browser/browser-status-layer.tsx +++ b/src/components/browser/browser-status-layer.tsx @@ -42,6 +42,11 @@ function noticeText( // has a host of `file`): name the whole thing, as typed by the page. return `${t("navigationBlocked", { host: notice.url })} · ${t("navigationBlockedScheme")}` } + // `external` / `download` are raised by document guests, which have a + // notice bar of their own; a browser tab only ever sees a site rule. + if (notice.reason !== "host-rule") { + return t("navigationBlocked", { host }) + } return `${t("navigationBlocked", { host })} · ${t("navigationBlockedRule")}` } const why = diff --git a/src/components/browser/browser-surface-host.test.tsx b/src/components/browser/browser-surface-host.test.tsx index ebb1f6c822..2f1f354ecb 100644 --- a/src/components/browser/browser-surface-host.test.tsx +++ b/src/components/browser/browser-surface-host.test.tsx @@ -6,6 +6,7 @@ import type { BrowserTabState, FrozenFrame } from "@/lib/browser/types" const api = vi.hoisted(() => ({ browserOpenTab: vi.fn(), + browserClose: vi.fn(() => Promise.resolve()), browserSetBounds: vi.fn(() => Promise.resolve()), browserSetVisible: vi.fn< ( @@ -17,6 +18,11 @@ const api = vi.hoisted(() => ({ >(() => Promise.resolve(null)), })) vi.mock("@/lib/browser/browser-api", () => api) +// `releaseBrowserTab` only talks to the backend on the desktop. +vi.mock("@/lib/transport", async (importOriginal) => ({ + ...(await importOriginal<typeof import("@/lib/transport")>()), + isDesktop: () => true, +})) vi.mock("@/contexts/workspace-context", () => ({ useWorkspaceView: () => ({ mode: "conversation", @@ -31,8 +37,11 @@ vi.mock("@/components/ui/overlay-host-hidden", () => ({ useOverlayHostHidden: () => false, })) -import { BrowserSurfaceHost } from "./browser-surface-host" -import { resetBrowserTabStoreForTests } from "@/lib/browser/browser-tab-store" +import { BrowserSurfaceHost, NativeSurfaceHost } from "./browser-surface-host" +import { + getBrowserTabState, + resetBrowserTabStoreForTests, +} from "@/lib/browser/browser-tab-store" import { acquireNativeSurfaceOcclusion, resetNativeSurfaceOcclusionForTests, @@ -58,6 +67,7 @@ function state(id = "abc"): BrowserTabState { return { tabId: id, ownerWindow: "main", + kind: "page", surface: "child", channel: "degraded", url: "", @@ -145,6 +155,33 @@ describe("BrowserSurfaceHost", () => { ) }) + it("tears a destroy-on-unmount surface down instead of hiding it", async () => { + const create = vi.fn(() => + Promise.resolve({ ...state("doc-1"), kind: "document" as const }) + ) + const { unmount } = render( + <NativeSurfaceHost + backendId="doc-1" + storeKey="browser:doc-1" + create={create} + destroyOnUnmount + /> + ) + await flush() + expect(create).toHaveBeenCalledTimes(1) + expect(getBrowserTabState("browser:doc-1")?.kind).toBe("document") + + unmount() + await flush() + expect(api.browserClose).toHaveBeenCalledWith("doc-1") + expect(getBrowserTabState("browser:doc-1")).toBeNull() + expect(api.browserSetVisible).not.toHaveBeenCalledWith( + "doc-1", + false, + false + ) + }) + it("drives an owned window from tab visibility, not from its invisible placeholder", async () => { // The tab view hides the placeholder (`invisible`) when the page lives in // its own window; the window must still be shown, and never sized. diff --git a/src/components/browser/browser-surface-host.tsx b/src/components/browser/browser-surface-host.tsx index aa4e1655ba..96cc11059a 100644 --- a/src/components/browser/browser-surface-host.tsx +++ b/src/components/browser/browser-surface-host.tsx @@ -20,6 +20,7 @@ import { hasSurfaceClaim, markBrowserTabHidden, markBrowserTabShown, + releaseBrowserTab, runSurfaceOp, setBrowserTabState, surfaceClaimIsCurrent, @@ -29,7 +30,7 @@ import { useFallbackOverlayOpen, useNativeSurfaceOccluded, } from "@/lib/browser/native-surface-occlusion" -import type { Bounds } from "@/lib/browser/types" +import type { Bounds, BrowserTabState } from "@/lib/browser/types" import { browserTabBackendId } from "@/lib/file-tab-id" import { cn } from "@/lib/utils" @@ -60,8 +61,29 @@ function elementVisible(el: HTMLElement): boolean { return true } +export interface NativeSurfaceHostProps { + /** The backend's id of the surface (label-safe). */ + backendId: string + /** Where the surface's state lives in the tab store: `browser:<backendId>` + * (the workspace tab id for a browser tab; a key of its own for a + * document guest hosted by a file tab). */ + storeKey: string + /** Create the surface at these bounds; resolves to its first state. Keep + * the identity stable for the life of the mount (memoize it): a new + * identity only re-syncs, never re-creates. */ + create: (bounds: Bounds) => Promise<BrowserTabState> + /** Tear the surface down when this host unmounts. A browser tab's surface + * belongs to its tab record and merely hides (the record outlives every + * host); a document guest belongs to the preview on screen and goes with + * it — the document comes back the same from disk. */ + destroyOnUnmount?: boolean + /** Force-hide (e.g. while a DOM error page replaces the page). */ + hidden?: boolean + className?: string +} + /** - * The placeholder a browser tab's native webview is fitted to. + * The placeholder a native webview is fitted to. * * The webview is a native view painted by the OS above the DOM at this * element's rect, so this component never renders page content itself. It @@ -71,17 +93,14 @@ function elementVisible(el: HTMLElement): boolean { * holds an occlusion lease, the tab is showing an error page — handing * keyboard focus back to the main webview first so the overlay gets Esc/Tab. */ -export function BrowserSurfaceHost({ - tab, +export function NativeSurfaceHost({ + backendId, + storeKey, + create, + destroyOnUnmount = false, hidden = false, className, -}: { - tab: BrowserWorkspaceTab - /** Force-hide (e.g. while a DOM error page replaces the page). */ - hidden?: boolean - className?: string -}) { - const backendId = browserTabBackendId(tab.id) +}: NativeSurfaceHostProps) { const ref = useRef<HTMLDivElement | null>(null) const lastBoundsRef = useRef<Bounds | null>(null) const lastVisibleRef = useRef<boolean | null>(null) @@ -120,11 +139,11 @@ export function BrowserSurfaceHost({ // flipped. Called from every signal that could change either. const sync = useCallback(() => { const el = ref.current - if (!el || !backendId) return + if (!el) return // An owned window is not fitted to this element — the placeholder is // invisible by design — so only the "is this tab on screen" signals // apply, and there are no bounds to push. - if (getBrowserTabState(tab.id)?.surface === "window") { + if (getBrowserTabState(storeKey)?.surface === "window") { if (lastVisibleRef.current !== shouldShow) { lastVisibleRef.current = shouldShow void browserSetVisible(backendId, shouldShow, !shouldShow).catch( @@ -169,14 +188,14 @@ export function BrowserSurfaceHost({ .catch(() => {}) } } - }, [backendId, overlayHide, shouldShow, tab.id]) + }, [backendId, overlayHide, shouldShow, storeKey]) // Whether this tab currently has a live surface. Also the re-creation // signal: if the state goes away while this host is mounted — the tab was // released by the background unload just as the user switched to it — the // effect below runs again and loads the page instead of leaving a blank // pane behind. - const loaded = useBrowserTabState(tab.id) !== null + const loaded = useBrowserTabState(storeKey) !== null // Create the surface once per tab record; adopted popups and re-mounts // already have one (the store knows about it). A record whose surface was @@ -184,8 +203,8 @@ export function BrowserSurfaceHost({ // here, at the URL the record was updated to. useEffect(() => { const el = ref.current - if (!el || !backendId) return - if (getBrowserTabState(tab.id)) { + if (!el) return + if (getBrowserTabState(storeKey)) { lastBoundsRef.current = null lastVisibleRef.current = null sync() @@ -199,21 +218,9 @@ export function BrowserSurfaceHost({ const bounds = measure(el) lastBoundsRef.current = bounds lastVisibleRef.current = true - // Preferences are read once, here: a surface cannot change its inspector - // or its kind after it exists, so a settings change applies to new tabs. - const prefs = getBrowserPrefs() // Queued per tab id: a close issued for an earlier generation must reach // the backend before this create, never after it. - runSurfaceOp(backendId, () => - browserOpenTab({ - tabId: backendId, - url: tab.browser.initialUrl, - bounds, - folderId: tab.folderId, - surface: prefs.surfaceOverride, - devtools: prefs.devtools, - }) - ) + runSurfaceOp(backendId, () => create(bounds)) .then((next) => { if (!surfaceClaimIsCurrent(backendId, token)) { // Someone else claimed this id meanwhile: their own create is @@ -238,12 +245,12 @@ export function BrowserSurfaceHost({ // Intentionally not re-run on `sync` identity changes: creation is a // one-shot per mount, the effect below handles every later sync. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendId, tab.id, tab.browser.initialUrl, tab.folderId, loaded]) + }, [backendId, storeKey, create, loaded]) // Geometry and visibility tracking for the life of the mount. useEffect(() => { const el = ref.current - if (!el || !backendId) return + if (!el) return let frame = 0 const schedule = () => { if (frame) return @@ -275,24 +282,29 @@ export function BrowserSurfaceHost({ }, [sync, view.mode, view.activePane, view.filesMaximized, routeVisible]) // Mount = the tab is on screen; unmount = it is no longer (another tab took - // the pane, the drawer closed, the panel went away). Hide, never destroy — - // the record owns the surface. The store keeps the timestamps so the - // optional background unload knows how long a page has been off screen. + // the pane, the drawer closed, the panel went away). A browser tab's + // surface hides, never dies — the record owns it, and the store keeps the + // timestamps so the optional background unload knows how long a page has + // been off screen. A document guest is torn down: it is the preview's, and + // the preview is gone. useEffect(() => { - if (!backendId) return - markBrowserTabShown(tab.id) + markBrowserTabShown(storeKey) return () => { lastVisibleRef.current = null hideSeqRef.current += 1 - markBrowserTabHidden(tab.id) - void browserSetVisible(backendId, false, false).catch(() => {}) + markBrowserTabHidden(storeKey) + if (destroyOnUnmount) { + releaseBrowserTab(storeKey) + } else { + void browserSetVisible(backendId, false, false).catch(() => {}) + } } - }, [backendId, tab.id]) + }, [backendId, destroyOnUnmount, storeKey]) return ( <div ref={ref} - data-browser-surface={backendId ?? undefined} + data-browser-surface={backendId} className={cn( "relative h-full w-full min-h-0 min-w-0 bg-background", className @@ -319,3 +331,50 @@ export function BrowserSurfaceHost({ </div> ) } + +/** + * The surface host of a browser tab: the workspace tab record names the + * backend id and the URL, and the surface is created with the preferences + * read at that moment (a surface cannot change its inspector or its kind + * after it exists, so a settings change applies to new tabs). + */ +export function BrowserSurfaceHost({ + tab, + hidden = false, + className, +}: { + tab: BrowserWorkspaceTab + /** Force-hide (e.g. while a DOM error page replaces the page). */ + hidden?: boolean + className?: string +}) { + const backendId = browserTabBackendId(tab.id) + const initialUrl = tab.browser.initialUrl + const folderId = tab.folderId + const create = useCallback( + (bounds: Bounds) => { + const prefs = getBrowserPrefs() + return browserOpenTab({ + tabId: backendId ?? "", + url: initialUrl, + bounds, + folderId, + surface: prefs.surfaceOverride, + devtools: prefs.devtools, + }) + }, + [backendId, folderId, initialUrl] + ) + // A browser tab always has a backend id; anything else is not a browser + // tab and gets no surface. + if (!backendId) return null + return ( + <NativeSurfaceHost + backendId={backendId} + storeKey={tab.id} + create={create} + hidden={hidden} + className={className} + /> + ) +} diff --git a/src/components/browser/browser-tabs-persistence.test.tsx b/src/components/browser/browser-tabs-persistence.test.tsx index b497d398f9..d3a470aa58 100644 --- a/src/components/browser/browser-tabs-persistence.test.tsx +++ b/src/components/browser/browser-tabs-persistence.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) ), @@ -101,6 +102,7 @@ describe("BrowserTabsPersistence", () => { setBrowserTabState({ tabId: "t1", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/a/deep", @@ -157,6 +159,7 @@ describe("BrowserTabsPersistence", () => { isolatedStorage: true, proxy: { url: null, applies: "live", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) await Promise.resolve() @@ -195,6 +198,7 @@ describe("BrowserTabsPersistence", () => { isolatedStorage: false, proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, }) render(<BrowserTabsPersistence />) diff --git a/src/components/browser/browser-tabs-suspender.test.tsx b/src/components/browser/browser-tabs-suspender.test.tsx index 85589c190a..270edab098 100644 --- a/src/components/browser/browser-tabs-suspender.test.tsx +++ b/src/components/browser/browser-tabs-suspender.test.tsx @@ -48,6 +48,7 @@ function loaded(id: string) { setBrowserTabState({ tabId: id, ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/", diff --git a/src/components/files/doc-guest-preview.test.tsx b/src/components/files/doc-guest-preview.test.tsx new file mode 100644 index 0000000000..4a8b8e8a18 --- /dev/null +++ b/src/components/files/doc-guest-preview.test.tsx @@ -0,0 +1,353 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" +import type { BrowserTabState, DocGuestState } from "@/lib/browser/types" + +const api = vi.hoisted(() => ({ + browserDocOpen: vi.fn(), + browserDocSetMode: vi.fn(), + browserReload: vi.fn(() => Promise.resolve()), + browserSetBounds: vi.fn(() => Promise.resolve()), + browserSetVisible: vi.fn(() => Promise.resolve(null)), + browserClose: vi.fn(() => Promise.resolve()), + browserOpenTab: vi.fn(), + openUrlTarget: vi.fn(), + openWithOsHandler: vi.fn(() => Promise.resolve()), +})) + +vi.mock("@/lib/browser/browser-api", () => ({ + browserDocOpen: api.browserDocOpen, + browserDocSetMode: api.browserDocSetMode, + browserReload: api.browserReload, + browserSetBounds: api.browserSetBounds, + browserSetVisible: api.browserSetVisible, + browserClose: api.browserClose, + browserOpenTab: api.browserOpenTab, +})) +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceView: () => ({ + mode: "conversation", + activePane: "files", + filesMaximized: false, + }), +})) +vi.mock("@/contexts/workbench-route-context", () => ({ + useOptionalWorkbenchRoute: () => null, +})) +vi.mock("@/components/ui/overlay-host-hidden", () => ({ + useOverlayHostHidden: () => false, +})) +vi.mock("@/hooks/use-open-url-target", () => ({ + useOpenUrlTarget: () => api.openUrlTarget, +})) +vi.mock("@/lib/link-open", () => ({ + openWithOsHandler: api.openWithOsHandler, +})) +// `releaseBrowserTab` only talks to the backend on the desktop. +vi.mock("@/lib/transport", async (importOriginal) => ({ + ...(await importOriginal<typeof import("@/lib/transport")>()), + isDesktop: () => true, +})) + +import enMessages from "@/i18n/messages/en.json" + +import { DocGuestPreview } from "./doc-guest-preview" +import { + browserWorkspaceTabId, + resetBrowserTabStoreForTests, + setBrowserTabNotice, + setBrowserTabState, + setDocGuestState, +} from "@/lib/browser/browser-tab-store" +import { resetBrowserPrefsForTests } from "@/lib/browser/browser-prefs" + +function tab(overrides: Partial<FileWorkspaceTab> = {}): FileWorkspaceTab { + return { + id: "file:%2Ftmp%2Fsite%2Freport.html", + kind: "file", + folderId: null, + title: "report.html", + description: null, + path: "/tmp/site/report.html", + language: "html", + content: "<!doctype html><title>Quarterly

hi

", + savedContent: "Quarterly

hi

", + loading: false, + ...overrides, + } as FileWorkspaceTab +} + +function tabState(tabId: string): BrowserTabState { + return { + tabId, + ownerWindow: "main", + kind: "document", + surface: "child", + channel: "degraded", + url: "", + requestedUrl: "codeg-doc://doc/report.html", + title: "", + favicon: null, + loading: true, + canGoBack: false, + canGoForward: false, + origin: null, + zoom: 1, + error: null, + remoteHost: null, + openerTabId: null, + } +} + +function docState( + tabId: string, + overrides: Partial = {} +): DocGuestState { + return { + tabId, + mode: "safe", + root: "/tmp/site", + entry: "/tmp/site/report.html", + url: "codeg-doc://doc/report.html", + reset: null, + ...overrides, + } +} + +async function flush() { + await act(async () => { + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +function renderPreview( + props: Partial[0]> = {} +) { + const onUseInline = vi.fn() + const view = render( + + + + ) + return { ...view, onUseInline } +} + +/** The backend id the preview minted for its file (stable per file tab). */ +function openedId(): string { + const calls = api.browserDocOpen.mock.calls + const call = calls[calls.length - 1]?.[0] as { tabId: string } | undefined + if (!call) throw new Error("browserDocOpen was not called") + return call.tabId +} + +describe("DocGuestPreview", () => { + beforeEach(() => { + resetBrowserTabStoreForTests() + resetBrowserPrefsForTests() + api.browserDocOpen.mockReset() + api.browserDocOpen.mockImplementation(({ tabId }: { tabId: string }) => + Promise.resolve({ state: tabState(tabId), doc: docState(tabId) }) + ) + api.browserDocSetMode.mockReset() + api.browserReload.mockClear() + api.browserClose.mockClear() + api.browserSetVisible.mockClear() + api.openUrlTarget.mockClear() + api.openWithOsHandler.mockClear() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it("opens the file through a guest confined to the given root and titles it", async () => { + renderPreview() + // Before the guest answers, the document's own names it. + expect(screen.getByTitle("Quarterly")).toBeInTheDocument() + await flush() + expect(api.browserDocOpen).toHaveBeenCalledTimes(1) + expect(api.browserDocOpen.mock.calls[0][0]).toMatchObject({ + path: "/tmp/site/report.html", + root: "/tmp/site", + }) + const id = openedId() + expect(id).toMatch(/^doc-[0-9a-f-]+$/) + // Safe mode: the switch offers to enable scripts. + expect( + screen.getByRole("button", { name: "Enable scripts" }) + ).toHaveAttribute("aria-pressed", "false") + // The page's title, once the guest reports one, wins. + act(() => setBrowserTabState({ ...tabState(id), title: "Live title" })) + expect(screen.getByTitle("Live title")).toBeInTheDocument() + }) + + it("confines to the file's own folder when no root is given", async () => { + renderPreview({ rootPath: null }) + await flush() + expect(api.browserDocOpen.mock.calls[0][0]).toMatchObject({ + root: "/tmp/site", + }) + }) + + it("switches modes through the backend", async () => { + renderPreview() + await flush() + const id = openedId() + api.browserDocSetMode.mockImplementation((tabId: string, mode: string) => + Promise.resolve(docState(tabId, { mode: mode as "safe" | "dynamic" })) + ) + fireEvent.click(screen.getByRole("button", { name: "Enable scripts" })) + await flush() + expect(api.browserDocSetMode).toHaveBeenCalledWith(id, "dynamic") + expect(screen.getByRole("button", { name: "Scripts on" })).toHaveAttribute( + "aria-pressed", + "true" + ) + fireEvent.click(screen.getByRole("button", { name: "Scripts on" })) + await flush() + expect(api.browserDocSetMode).toHaveBeenLastCalledWith(id, "safe") + }) + + it("offers re-approval when the backend drops back to safe mode", async () => { + renderPreview() + await flush() + const id = openedId() + api.browserReload.mockClear() + act(() => + setDocGuestState( + docState(id, { + mode: "safe", + reset: { path: "app.js", reason: "newer" }, + }) + ) + ) + expect( + screen.getByText( + "app.js changed after scripts were enabled. Scripts are off again." + ) + ).toBeInTheDocument() + // The backend reloaded the document itself; the preview does not. + expect(api.browserReload).not.toHaveBeenCalled() + api.browserDocSetMode.mockImplementation((tabId: string) => + Promise.resolve(docState(tabId, { mode: "dynamic" })) + ) + fireEvent.click(screen.getByRole("button", { name: "Enable again" })) + await flush() + expect(api.browserDocSetMode).toHaveBeenCalledWith(id, "dynamic") + expect(screen.queryByText(/changed after scripts/)).not.toBeInTheDocument() + }) + + it("offers to open a web link the document pointed at, through the app's link decision", async () => { + renderPreview() + await flush() + const key = browserWorkspaceTabId(openedId()) + act(() => + setBrowserTabNotice(key, { + kind: "navigation-blocked", + url: "https://example.com/x", + reason: "external", + }) + ) + expect( + screen.getByText("Link to example.com was not followed") + ).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Open link" })) + expect(api.openUrlTarget).toHaveBeenCalledWith("https://example.com/x", { + source: "editor", + }) + expect(screen.queryByText(/was not followed/)).not.toBeInTheDocument() + }) + + it("hands a mailto: link to the system and reports a refused download", async () => { + renderPreview() + await flush() + const key = browserWorkspaceTabId(openedId()) + act(() => + setBrowserTabNotice(key, { + kind: "navigation-blocked", + url: "mailto:a@example.com", + reason: "scheme", + }) + ) + fireEvent.click( + screen.getByRole("button", { name: "Open with system app" }) + ) + expect(api.openWithOsHandler).toHaveBeenCalledWith("mailto:a@example.com") + act(() => + setBrowserTabNotice(key, { + kind: "navigation-blocked", + url: "codeg-doc://doc/big.zip", + reason: "download", + }) + ) + expect( + screen.getByText("Downloads are not allowed in a document preview") + ).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Open link" })).toBeNull() + }) + + it("reloads the guest when the saved file changes, and flags unsaved edits", async () => { + const { rerender } = renderPreview() + await flush() + const id = openedId() + api.browserReload.mockClear() + rerender( + <NextIntlClientProvider locale="en" messages={enMessages}> + <DocGuestPreview + tab={tab({ content: "<p>edited</p>", isDirty: true })} + rootPath="/tmp/site" + onUseInline={vi.fn()} + /> + </NextIntlClientProvider> + ) + // An unsaved edit is not on disk: no reload, a hint instead. + expect(api.browserReload).not.toHaveBeenCalled() + expect( + screen.getByText("Unsaved changes are not shown") + ).toBeInTheDocument() + rerender( + <NextIntlClientProvider locale="en" messages={enMessages}> + <DocGuestPreview + tab={tab({ content: "<p>edited</p>", savedContent: "<p>edited</p>" })} + rootPath="/tmp/site" + onUseInline={vi.fn()} + /> + </NextIntlClientProvider> + ) + expect(api.browserReload).toHaveBeenCalledWith(id) + }) + + it("tears the guest down on unmount and reuses its id on the next mount", async () => { + const first = renderPreview() + await flush() + const id = openedId() + first.unmount() + await flush() + expect(api.browserClose).toHaveBeenCalledWith(id) + + renderPreview() + await flush() + expect(api.browserDocOpen).toHaveBeenCalledTimes(2) + expect(openedId()).toBe(id) + }) + + it("offers the inline renderer from its menu", async () => { + const { onUseInline } = renderPreview() + await flush() + // Radix opens its menu from the keyboard (a pointer needs pointerdown). + fireEvent.keyDown(screen.getByRole("button", { name: "More" }), { + key: "Enter", + }) + fireEvent.click( + await screen.findByRole("menuitem", { name: "Use inline preview" }) + ) + expect(onUseInline).toHaveBeenCalled() + }) +}) diff --git a/src/components/files/doc-guest-preview.tsx b/src/components/files/doc-guest-preview.tsx new file mode 100644 index 0000000000..47ac8d5e63 --- /dev/null +++ b/src/components/files/doc-guest-preview.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { + Copy, + MoreHorizontal, + RotateCw, + ShieldAlert, + ShieldCheck, + ShieldOff, + X, +} from "lucide-react" + +import { NativeSurfaceHost } from "@/components/browser/browser-surface-host" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import type { FileWorkspaceTab } from "@/contexts/workspace-context" +import { useOpenUrlTarget } from "@/hooks/use-open-url-target" +import { + browserDocOpen, + browserDocSetMode, + browserReload, +} from "@/lib/browser/browser-api" +import { getBrowserPrefs } from "@/lib/browser/browser-prefs" +import { + browserWorkspaceTabId, + setBrowserTabNotice, + setDocGuestState, + useBrowserTabNotice, + useBrowserTabState, + useDocGuestState, +} from "@/lib/browser/browser-tab-store" +import { displayHostPort } from "@/lib/browser/browser-url" +import type { Bounds, DocMode, DocReset } from "@/lib/browser/types" +import { extractHtmlTitle } from "@/lib/html-preview-inline" +import { getAllowedExternalProtocol } from "@/lib/link-classify" +import { openWithOsHandler } from "@/lib/link-open" +import { cn, copyTextToClipboard } from "@/lib/utils" + +// One backend id per file tab for the session. The guest is torn down +// whenever the file leaves the screen and created again when it returns — +// under the same id, so the store's per-id ordering of create and close +// applies across the two. +const backendIds = new Map<string, string>() + +function backendIdFor(fileTabId: string): string { + let id = backendIds.get(fileTabId) + if (!id) { + id = `doc-${crypto.randomUUID()}` + backendIds.set(fileTabId, id) + } + return id +} + +function dirname(path: string): string { + const cut = path.replace(/[\\/]+$/, "") + const at = Math.max(cut.lastIndexOf("/"), cut.lastIndexOf("\\")) + return at > 0 ? cut.slice(0, at) : cut +} + +function basename(path: string): string { + return path.split(/[\\/]/).pop() ?? path +} + +const headerBtn = + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs transition-colors text-muted-foreground hover:bg-primary/8 disabled:opacity-50" +const iconBtn = + "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-primary/8 disabled:opacity-50" + +/** + * An HTML file shown through the document guest (see the Rust `doc_guest` + * module): a native surface in the preview's slot, served by the backend + * from the file's folder. Safe mode by default — no script, no connection — + * and a per-file switch to dynamic mode, which the backend revokes on its + * own if any served file changes afterwards. + * + * Deliberately NOT a browser tab: the file column, the viewer drawer and + * the canvas card all render `HtmlPreview`, so hosting the guest inside it + * switches every entrance at once, keeps one tab per file, and needs no new + * persistence — a guest is recreated from disk whenever its preview mounts. + */ +export function DocGuestPreview({ + tab, + rootPath, + onUseInline, +}: { + tab: FileWorkspaceTab + /** Sub-resource resolution root: the owning workspace folder, else null + * (the file's own directory). Same value the inline preview takes. */ + rootPath: string | null + /** The user prefers the inline preview for this file. */ + onUseInline: () => void +}) { + const t = useTranslations("Browser.doc") + const path = tab.path ?? "" + const backendId = useMemo(() => backendIdFor(tab.id), [tab.id]) + const storeKey = browserWorkspaceTabId(backendId) + const state = useBrowserTabState(storeKey) + const doc = useDocGuestState(storeKey) + const notice = useBrowserTabNotice(storeKey) + const openUrlTarget = useOpenUrlTarget() + const [switching, setSwitching] = useState(false) + const [dismissedReset, setDismissedReset] = useState<DocReset | null>(null) + const root = rootPath ?? dirname(path) + + const create = useCallback( + (bounds: Bounds) => + browserDocOpen({ + tabId: backendId, + path, + root, + bounds, + devtools: getBrowserPrefs().devtools, + }).then((result) => { + setDocGuestState(result.doc) + return result.state + }), + [backendId, path, root] + ) + + // The file on disk changed under the guest — a save from the editor, an + // external change the watcher picked up: show the new one. The first value + // is the one the guest loaded. + const savedRef = useRef(tab.savedContent) + useEffect(() => { + if (savedRef.current === tab.savedContent) return + savedRef.current = tab.savedContent + if (state) void browserReload(backendId).catch(() => {}) + }, [backendId, state, tab.savedContent]) + + // When the backend drops the guest back to safe mode (a served file + // changed after scripts were enabled) it reloads the document itself; the + // preview only has to say so, and offer to approve again. + const mode: DocMode = doc?.mode ?? "safe" + const setMode = useCallback( + async (next: DocMode) => { + setSwitching(true) + try { + setDocGuestState(await browserDocSetMode(backendId, next)) + } catch { + /* the guest is gone; the next mount starts over */ + } finally { + setSwitching(false) + } + }, + [backendId] + ) + + const heading = + state?.title || extractHtmlTitle(tab.content ?? "") || basename(path) + const error = state?.error ?? null + const reset = doc?.reset && doc.reset !== dismissedReset ? doc.reset : null + + return ( + <div className="flex h-full min-h-0 flex-col"> + <div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b border-border bg-muted/20 px-3"> + <span + className="min-w-0 truncate text-xs font-medium text-foreground/80" + title={heading || undefined} + > + {heading} + </span> + <div className="flex shrink-0 items-center gap-0.5"> + {tab.isDirty ? ( + <span className="mr-1 truncate text-2xs text-muted-foreground"> + {t("unsaved")} + </span> + ) : null} + <button + type="button" + onClick={() => + void setMode(mode === "dynamic" ? "safe" : "dynamic") + } + aria-pressed={mode === "dynamic"} + disabled={switching || !state} + title={t("scriptsHint")} + className={cn( + headerBtn, + mode === "dynamic" && + "text-amber-600 hover:bg-amber-500/10 dark:text-amber-500" + )} + > + {mode === "dynamic" ? ( + <ShieldOff className="h-3.5 w-3.5" /> + ) : ( + <ShieldCheck className="h-3.5 w-3.5" /> + )} + {mode === "dynamic" ? t("scriptsOn") : t("scriptsOff")} + </button> + <button + type="button" + className={iconBtn} + title={t("reload")} + aria-label={t("reload")} + disabled={!state} + onClick={() => void browserReload(backendId).catch(() => {})} + > + <RotateCw className="h-3.5 w-3.5" /> + </button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className={iconBtn} + title={t("more")} + aria-label={t("more")} + > + <MoreHorizontal className="h-3.5 w-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onSelect={() => void copyTextToClipboard(path)}> + <Copy className="h-3.5 w-3.5" /> + {t("copyPath")} + </DropdownMenuItem> + <DropdownMenuItem onSelect={onUseInline}> + {t("useInline")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + {reset ? ( + <NoticeRow + text={t("reset", { file: reset.path })} + onDismiss={() => setDismissedReset(reset)} + > + <NoticeAction + label={t("enableAgain")} + onClick={() => { + setDismissedReset(reset) + void setMode("dynamic") + }} + /> + </NoticeRow> + ) : null} + {notice ? ( + <NoticeRow + text={ + notice.kind === "navigation-blocked" && notice.reason === "download" + ? t("downloadRefused") + : notice.reason === "external" || + (notice.kind === "popup-denied" && + /^https?:/i.test(notice.url)) + ? t("externalLink", { + host: displayHostPort(notice.url) ?? notice.url, + }) + : t("schemeBlocked", { url: notice.url }) + } + onDismiss={() => setBrowserTabNotice(storeKey, null)} + > + {notice.reason === "external" || + (notice.kind === "popup-denied" && /^https?:/i.test(notice.url)) ? ( + <NoticeAction + label={t("openLink")} + onClick={() => { + // The same decision every link in the app takes: the user's + // default for the editor, site rules included. + openUrlTarget(notice.url, { source: "editor" }) + setBrowserTabNotice(storeKey, null) + }} + /> + ) : notice.kind === "navigation-blocked" && + notice.reason === "scheme" && + getAllowedExternalProtocol(notice.url) ? ( + <NoticeAction + label={t("openWithSystem")} + onClick={() => { + void openWithOsHandler(notice.url) + setBrowserTabNotice(storeKey, null) + }} + /> + ) : null} + </NoticeRow> + ) : null} + <div className="relative min-h-0 flex-1"> + <NativeSurfaceHost + backendId={backendId} + storeKey={storeKey} + create={create} + destroyOnUnmount + hidden={error !== null} + className={error ? "invisible" : undefined} + /> + {error ? ( + <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background px-6 text-center"> + <ShieldAlert className="h-8 w-8 text-muted-foreground/60" /> + <p className="text-sm font-medium text-foreground"> + {t("cannotShow")} + </p> + {error.message ? ( + <p className="max-w-md text-xs text-muted-foreground/80"> + {error.message} + </p> + ) : null} + <button + type="button" + className="mt-1 inline-flex h-7 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs hover:bg-primary/8" + onClick={() => void browserReload(backendId).catch(() => {})} + > + <RotateCw className="h-3.5 w-3.5" /> + {t("reload")} + </button> + </div> + ) : null} + </div> + </div> + ) +} + +/** A dismissible line between the header and the document. Outside the + * native surface's rect on purpose: a native view paints over any DOM + * placed on top of it. */ +function NoticeRow({ + text, + onDismiss, + children, +}: { + text: string + onDismiss: () => void + children?: React.ReactNode +}) { + const t = useTranslations("Browser.doc") + return ( + <div + role="status" + className="flex h-8 shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 text-xs text-foreground" + > + <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-amber-600" /> + <span className="min-w-0 flex-1 truncate" title={text}> + {text} + </span> + {children} + <button + type="button" + className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-primary/8" + title={t("dismiss")} + aria-label={t("dismiss")} + onClick={onDismiss} + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ) +} + +function NoticeAction({ + label, + onClick, +}: { + label: string + onClick: () => void +}) { + return ( + <button + type="button" + className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/8" + onClick={onClick} + > + {label} + </button> + ) +} diff --git a/src/components/files/html-preview.test.tsx b/src/components/files/html-preview.test.tsx new file mode 100644 index 0000000000..11d7258a44 --- /dev/null +++ b/src/components/files/html-preview.test.tsx @@ -0,0 +1,139 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { FileWorkspaceTab } from "@/contexts/workspace-context" +import type { BrowserCapabilities } from "@/lib/browser/types" + +const mocks = vi.hoisted(() => ({ + capabilities: null as BrowserCapabilities | null, +})) + +vi.mock("@/lib/browser/use-browser-capabilities", () => ({ + useBrowserCapabilities: () => mocks.capabilities, +})) +vi.mock("@/components/files/doc-guest-preview", () => ({ + DocGuestPreview: ({ onUseInline }: { onUseInline: () => void }) => ( + <div data-testid="doc-guest"> + <button type="button" onClick={onUseInline}> + inline please + </button> + </div> + ), +})) +vi.mock("@/lib/api", () => ({ + readWorkspaceFileBase64: () => Promise.resolve(""), +})) + +import enMessages from "@/i18n/messages/en.json" + +import { + HtmlPreview, + resetHtmlPreviewEngineOverridesForTests, +} from "./html-preview" +import { + resetBrowserPrefsForTests, + setBrowserHtmlPreviewEngine, +} from "@/lib/browser/browser-prefs" + +const CAPS: BrowserCapabilities = { + available: true, + surface: "child", + platform: "macos", + channel: "native", + reasons: [], + isolatedStorage: true, + proxy: { url: null, applies: "live", reason: null }, + downloadsDir: "/Users/dev/Downloads", + policy: { enabled: true, managedRules: [], managedSource: null }, + docGuest: true, +} + +function tab(id = "file:%2Ftmp%2Fa.html"): FileWorkspaceTab { + return { + id, + kind: "file", + folderId: null, + title: "a.html", + description: null, + path: "/tmp/a.html", + language: "html", + content: "<!doctype html><title>Hello

hi

", + loading: false, + } +} + +function renderPreview(t = tab()) { + return render( + + + + ) +} + +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +describe("HtmlPreview engine choice", () => { + beforeEach(() => { + resetBrowserPrefsForTests() + resetHtmlPreviewEngineOverridesForTests() + mocks.capabilities = CAPS + }) + + it("renders the document guest where the backend offers one", () => { + renderPreview() + expect(screen.getByTestId("doc-guest")).toBeInTheDocument() + expect(screen.queryByTitle("HTML preview")).not.toBeInTheDocument() + }) + + it("falls back to the inline renderer without a guest, with no way to switch", async () => { + mocks.capabilities = { ...CAPS, docGuest: false } + renderPreview() + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + expect( + screen.queryByLabelText("Use built-in browser preview") + ).not.toBeInTheDocument() + }) + + it("treats an unknown answer as no guest", async () => { + mocks.capabilities = null + renderPreview() + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + }) + + it("follows the setting, and a per-file choice made from the preview overrides it", async () => { + setBrowserHtmlPreviewEngine("inline") + renderPreview() + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + + // The inline header offers the guest; the guest's menu offers inline. + fireEvent.click(screen.getByLabelText("Use built-in browser preview")) + expect(screen.getByTestId("doc-guest")).toBeInTheDocument() + fireEvent.click(screen.getByText("inline please")) + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + expect(screen.queryByTestId("doc-guest")).not.toBeInTheDocument() + }) + + it("keeps the per-file choice across mounts, per file", async () => { + renderPreview().unmount() + const first = renderPreview() + fireEvent.click(screen.getByText("inline please")) + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + first.unmount() + // Same file again: still inline. Another file: the default (guest). + renderPreview() + await flush() + expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + renderPreview(tab("file:%2Ftmp%2Fb.html")) + expect(screen.getByTestId("doc-guest")).toBeInTheDocument() + }) +}) diff --git a/src/components/files/html-preview.tsx b/src/components/files/html-preview.tsx index 94859b0692..43ccef0814 100644 --- a/src/components/files/html-preview.tsx +++ b/src/components/files/html-preview.tsx @@ -1,8 +1,14 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react" import { useTranslations } from "next-intl" -import { ShieldCheck, ShieldOff } from "lucide-react" +import { AppWindow, ShieldCheck, ShieldOff } from "lucide-react" import { readWorkspaceFileBase64 } from "@/lib/api" import { extractHtmlTitle, @@ -10,6 +16,12 @@ import { withSandboxCsp, } from "@/lib/html-preview-inline" import type { FileWorkspaceTab } from "@/contexts/workspace-context" +import { DocGuestPreview } from "@/components/files/doc-guest-preview" +import { + useBrowserPrefs, + type HtmlPreviewEngine, +} from "@/lib/browser/browser-prefs" +import { useBrowserCapabilities } from "@/lib/browser/use-browser-capabilities" import { cn } from "@/lib/utils" // Trusted sandbox: scripts run, popups/forms/modals work, but the frame still @@ -19,6 +31,44 @@ import { cn } from "@/lib/utils" // the actual in-app security boundary for previewing untrusted HTML. const SANDBOX_TRUSTED = "allow-scripts allow-popups allow-forms allow-modals" +// A per-file choice of engine made from a preview's own menu, kept for the +// session (a preview unmounts whenever its file leaves the screen). Module +// state with a tiny subscription so every mount of the same file agrees. +const engineOverrides = new Map() +const overrideListeners = new Set<() => void>() + +function setEngineOverride(tabId: string, engine: HtmlPreviewEngine): void { + engineOverrides.set(tabId, engine) + for (const listener of [...overrideListeners]) listener() +} + +function subscribeOverrides(listener: () => void): () => void { + overrideListeners.add(listener) + return () => { + overrideListeners.delete(listener) + } +} + +function useEngineOverride(tabId: string): HtmlPreviewEngine | null { + return useSyncExternalStore( + subscribeOverrides, + () => engineOverrides.get(tabId) ?? null, + () => null + ) +} + +/** Tests only. */ +export function resetHtmlPreviewEngineOverridesForTests(): void { + engineOverrides.clear() +} + +/** + * The preview of an HTML file. On the desktop, where the backend offers the + * document guest, that is what renders it (see `DocGuestPreview`); the inline + * `srcdoc` iframe remains the web build's renderer and one menu choice away + * everywhere. The choice is the user's setting, overridden per file from the + * preview's own menu. + */ export function HtmlPreview({ tab, rootPath, @@ -29,8 +79,55 @@ export function HtmlPreview({ // working), else the file's own directory — a natural sandbox for // outside-workspace files. The tab path itself is absolute. rootPath: string | null +}) { + const prefs = useBrowserPrefs() + const capabilities = useBrowserCapabilities() + const override = useEngineOverride(tab.id) + const guestAvailable = capabilities?.docGuest === true && Boolean(tab.path) + const engine: HtmlPreviewEngine = !guestAvailable + ? "inline" + : (override ?? prefs.htmlPreviewEngine) + const tabId = tab.id + const useInline = useCallback( + () => setEngineOverride(tabId, "inline"), + [tabId] + ) + const useGuest = useCallback(() => setEngineOverride(tabId, "guest"), [tabId]) + if (engine === "guest") { + return ( + + ) + } + return ( + + ) +} + +/** The inline renderer: the file's markup, its local sub-resources inlined, + * in a sandboxed `srcdoc` iframe. */ +export function InlineHtmlPreview({ + tab, + rootPath, + onUseGuest, +}: { + tab: FileWorkspaceTab + rootPath: string | null + /** Offered when a document guest is available and the user chose the + * inline renderer for this file. */ + onUseGuest: (() => void) | null }) { const t = useTranslations("Folder.fileWorkspacePanel") + const tDoc = useTranslations("Browser.doc") const [inlined, setInlined] = useState(null) const [error, setError] = useState(null) const [trusted, setTrusted] = useState(false) @@ -96,25 +193,38 @@ export function HtmlPreview({ > {heading} - +
+ + {onUseGuest ? ( + + ) : null} +
{loading && ( diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx index dbacdcf59e..377431f2f9 100644 --- a/src/components/settings/browser-settings.test.tsx +++ b/src/components/settings/browser-settings.test.tsx @@ -50,6 +50,7 @@ function capabilitiesWith(proxy: { isolatedStorage: true, proxy, downloadsDir: "/Users/dev/Downloads", + docGuest: true, policy: { enabled: true, managedRules: [], managedSource: null }, } } @@ -184,6 +185,33 @@ describe("BrowserSettingsSection", () => { expect(getBrowserPrefs().terminalClickMenu).toBe(false) }) + it("persists the HTML preview engine switch, on by default", async () => { + renderSection() + expandSection() + const toggle = screen.getByLabelText("HTML file previews") + expect(toggle).toBeChecked() + await waitFor(() => expect(toggle).not.toBeDisabled()) + + fireEvent.click(toggle) + expect(getBrowserPrefs().htmlPreviewEngine).toBe("inline") + expect(screen.getByLabelText("HTML file previews")).not.toBeChecked() + + fireEvent.click(screen.getByLabelText("HTML file previews")) + expect(getBrowserPrefs().htmlPreviewEngine).toBe("guest") + }) + + it("leaves the HTML preview switch inert where no document guest exists", async () => { + mocks.browserCapabilitiesNow.mockResolvedValue({ + ...capabilitiesWith({ url: null, applies: "live", reason: null }), + docGuest: false, + }) + renderSection() + expandSection() + await waitFor(() => + expect(screen.getByLabelText("HTML file previews")).toBeDisabled() + ) + }) + it("persists the inspector switch and follows a change made elsewhere", () => { renderSection() expandSection() diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx index 884a0a5589..6b37a1cdaf 100644 --- a/src/components/settings/browser-settings.tsx +++ b/src/components/settings/browser-settings.tsx @@ -19,6 +19,7 @@ import { AppWindow, Download, Eraser, + FileCode2, Globe, Link2, ListFilter, @@ -63,6 +64,7 @@ import { LINK_SOURCES, setBrowserDevtools, setBrowserHostRules, + setBrowserHtmlPreviewEngine, setBrowserSurfaceOverride, setBrowserSuspendBackgroundTabs, setBrowserTerminalClickMenu, @@ -327,6 +329,9 @@ export function BrowserSettingsSection() { const [proxy, setProxy] = useState(null) const [downloadsDir, setDownloadsDir] = useState(null) const [policy, setPolicy] = useState(null) + // Whether this build hosts document guests (null until known); the HTML + // preview switch is inert where there is none to switch to. + const [docGuest, setDocGuest] = useState(null) // Fetched when the section opens (not once per app run): the answer follows // the proxy setting, which lives on another settings page. @@ -339,12 +344,14 @@ export function BrowserSettingsSection() { setProxy(caps.proxy) setDownloadsDir(caps.downloadsDir || null) setPolicy(caps.policy ?? null) + setDocGuest(caps.docGuest ?? false) }) .catch(() => { if (cancelled) return setProxy(null) setDownloadsDir(null) setPolicy(null) + setDocGuest(null) }) return () => { cancelled = true @@ -454,6 +461,22 @@ export function BrowserSettingsSection() { /> } /> + + setBrowserHtmlPreviewEngine(enabled ? "guest" : "inline") + } + /> + } + /> { setBrowserTabState({ tabId: backendId, ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/docs/deep", @@ -3609,6 +3610,7 @@ describe("browser tabs", () => { setBrowserTabState({ tabId: opened.id.slice("browser:".length), ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/docs/deep", diff --git a/src/hooks/use-open-url-target.test.tsx b/src/hooks/use-open-url-target.test.tsx index b4235a5bb7..39def4d927 100644 --- a/src/hooks/use-open-url-target.test.tsx +++ b/src/hooks/use-open-url-target.test.tsx @@ -52,6 +52,7 @@ const AVAILABLE = { isolatedStorage: true, proxy: { url: null, applies: "live" as const, reason: null }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [], managedSource: null }, } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7771ca8559..13d42e4023 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "لا توجد قواعد بعد.", "terminalMenuTitle": "قائمة روابط الطرفية", "terminalMenuHint": "عند النقر على رابط في الطرفية تظهر قائمة صغيرة (المتصفح المدمج، متصفح النظام، نسخ الرابط) بدلاً من فتحه فورًا. لا يزال النقر مع ⌘/Ctrl يفتح الرابط مباشرة.", + "htmlPreviewTitle": "معاينة ملفات HTML", + "htmlPreviewHint": "عرض ملفات HTML عبر المتصفح المدمج في عرض مستند مقيَّد: لا برامج نصية حتى تفعّلها لملف ما، ولا يمكن الوصول إلا إلى مجلده. عند الإيقاف تُستخدم المعاينة المضمّنة كما في تطبيق الويب.", "managedDisabled": "أوقفت سياسة المسؤول المتصفح المدمج؛ تُفتح الروابط في متصفح النظام." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "فتح في المتصفح المدمج", "openSystem": "فتح في متصفح النظام", "copy": "نسخ الرابط" + }, + "doc": { + "scriptsOff": "تفعيل البرامج النصية", + "scriptsOn": "البرامج النصية مفعّلة", + "scriptsHint": "تشغيل البرامج النصية لهذا المستند. لا يمكنها سوى قراءة الملفات في مجلده ولا يمكنها الوصول إلى الشبكة. إذا تغيّر أي من هذه الملفات لاحقًا، تُعطَّل البرامج النصية مجددًا.", + "reload": "إعادة التحميل", + "more": "المزيد", + "copyPath": "نسخ المسار", + "useInline": "استخدام المعاينة المضمّنة", + "useGuest": "استخدام معاينة المتصفح المدمج", + "unsaved": "لا تُعرض التغييرات غير المحفوظة", + "reset": "تغيّر {file} بعد تفعيل البرامج النصية. عُطِّلت البرامج النصية مجددًا.", + "enableAgain": "التفعيل مجددًا", + "dismiss": "إغلاق", + "externalLink": "لم يُتَّبع الرابط إلى {host}", + "openLink": "فتح الرابط", + "downloadRefused": "لا يُسمح بالتنزيل في معاينة المستند", + "openWithSystem": "فتح بتطبيق النظام", + "schemeBlocked": "لا يمكن فتح {url} هنا", + "cannotShow": "تعذّر عرض هذا المستند" } } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f0f4e93ffa..88a12882cc 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "Noch keine Regeln.", "terminalMenuTitle": "Link-Menü im Terminal", "terminalMenuHint": "Ein Klick auf einen Link im Terminal zeigt ein kleines Menü (integrierter Browser, Systembrowser, Link kopieren), statt ihn sofort zu öffnen. ⌘/Strg-Klick öffnet den Link weiterhin direkt.", + "htmlPreviewTitle": "Vorschau von HTML-Dateien", + "htmlPreviewHint": "Zeigt HTML-Dateien über den integrierten Browser in einer abgeschotteten Dokumentansicht: keine Skripte, bis du sie für eine Datei aktivierst, und nur der eigene Ordner ist erreichbar. Aus: Die Inline-Vorschau wird verwendet, wie in der Web-App.", "managedDisabled": "Die Richtlinie der Administration hat den integrierten Browser abgeschaltet; Links öffnen sich im Systembrowser." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "Im integrierten Browser öffnen", "openSystem": "Im Systembrowser öffnen", "copy": "Link kopieren" + }, + "doc": { + "scriptsOff": "Skripte aktivieren", + "scriptsOn": "Skripte aktiv", + "scriptsHint": "Führt die Skripte dieses Dokuments aus. Sie können nur Dateien aus seinem Ordner lesen und haben keinen Netzwerkzugriff. Ändert sich danach eine dieser Dateien, werden Skripte wieder deaktiviert.", + "reload": "Neu laden", + "more": "Mehr", + "copyPath": "Pfad kopieren", + "useInline": "Inline-Vorschau verwenden", + "useGuest": "Vorschau im integrierten Browser verwenden", + "unsaved": "Ungespeicherte Änderungen werden nicht angezeigt", + "reset": "{file} wurde geändert, nachdem Skripte aktiviert wurden. Skripte sind wieder deaktiviert.", + "enableAgain": "Erneut aktivieren", + "dismiss": "Schließen", + "externalLink": "Dem Link zu {host} wurde nicht gefolgt", + "openLink": "Link öffnen", + "downloadRefused": "In einer Dokumentvorschau sind keine Downloads erlaubt", + "openWithSystem": "Mit System-App öffnen", + "schemeBlocked": "{url} kann hier nicht geöffnet werden", + "cannotShow": "Dieses Dokument kann nicht angezeigt werden" } } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8f8424fece..d63f8ad9fd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "No rules yet.", "terminalMenuTitle": "Terminal link menu", "terminalMenuHint": "Clicking a link in the terminal shows a small menu (built-in browser, system browser, copy link) instead of opening it right away. ⌘/Ctrl-click still opens the link directly.", + "htmlPreviewTitle": "HTML file previews", + "htmlPreviewHint": "Show HTML files through the built-in browser, in a locked-down document view: no scripts until you enable them for a file, and only its own folder is reachable. Off: the inline preview is used, as in the web app.", "managedDisabled": "The built-in browser is turned off by your administrator's policy; links open in the system browser." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "Open in built-in browser", "openSystem": "Open in system browser", "copy": "Copy link" + }, + "doc": { + "scriptsOff": "Enable scripts", + "scriptsOn": "Scripts on", + "scriptsHint": "Run this document's scripts. They can only read files in its folder and cannot reach the network. If any of those files changes later, scripts turn off again.", + "reload": "Reload", + "more": "More", + "copyPath": "Copy path", + "useInline": "Use inline preview", + "useGuest": "Use built-in browser preview", + "unsaved": "Unsaved changes are not shown", + "reset": "{file} changed after scripts were enabled. Scripts are off again.", + "enableAgain": "Enable again", + "dismiss": "Dismiss", + "externalLink": "Link to {host} was not followed", + "openLink": "Open link", + "downloadRefused": "Downloads are not allowed in a document preview", + "openWithSystem": "Open with system app", + "schemeBlocked": "{url} can't be opened here", + "cannotShow": "This document can't be shown" } } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 8948720e6a..858169f379 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "Aún no hay reglas.", "terminalMenuTitle": "Menú de enlaces del terminal", "terminalMenuHint": "Al hacer clic en un enlace del terminal se muestra un pequeño menú (navegador integrado, navegador del sistema, copiar enlace) en lugar de abrirlo de inmediato. ⌘/Ctrl+clic sigue abriendo el enlace directamente.", + "htmlPreviewTitle": "Vista previa de archivos HTML", + "htmlPreviewHint": "Muestra los archivos HTML a través del navegador integrado, en una vista de documento restringida: sin scripts hasta que los actives para un archivo, y solo con acceso a su propia carpeta. Desactivado: se usa la vista previa en línea, como en la aplicación web.", "managedDisabled": "La política de tu administrador ha desactivado el navegador integrado; los enlaces se abren en el navegador del sistema." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "Abrir en el navegador integrado", "openSystem": "Abrir en el navegador del sistema", "copy": "Copiar enlace" + }, + "doc": { + "scriptsOff": "Activar scripts", + "scriptsOn": "Scripts activados", + "scriptsHint": "Ejecuta los scripts de este documento. Solo pueden leer archivos de su carpeta y no tienen acceso a la red. Si después cambia alguno de esos archivos, los scripts se desactivan de nuevo.", + "reload": "Recargar", + "more": "Más", + "copyPath": "Copiar ruta", + "useInline": "Usar vista previa en línea", + "useGuest": "Usar la vista previa del navegador integrado", + "unsaved": "Los cambios sin guardar no se muestran", + "reset": "{file} cambió después de activar los scripts. Los scripts están desactivados de nuevo.", + "enableAgain": "Activar de nuevo", + "dismiss": "Cerrar", + "externalLink": "No se siguió el enlace a {host}", + "openLink": "Abrir enlace", + "downloadRefused": "No se permiten descargas en la vista previa de un documento", + "openWithSystem": "Abrir con la aplicación del sistema", + "schemeBlocked": "{url} no se puede abrir aquí", + "cannotShow": "No se puede mostrar este documento" } } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6c8b5eccbc..89073c37a1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "Aucune règle pour l'instant.", "terminalMenuTitle": "Menu des liens du terminal", "terminalMenuHint": "Un clic sur un lien dans le terminal affiche un petit menu (navigateur intégré, navigateur système, copier le lien) au lieu de l'ouvrir immédiatement. ⌘/Ctrl-clic ouvre toujours le lien directement.", + "htmlPreviewTitle": "Aperçu des fichiers HTML", + "htmlPreviewHint": "Affiche les fichiers HTML via le navigateur intégré, dans une vue document verrouillée : aucun script tant que vous ne l'activez pas pour un fichier, et seul son propre dossier est accessible. Désactivé : l'aperçu en ligne est utilisé, comme dans l'application web.", "managedDisabled": "La politique de votre administrateur a désactivé le navigateur intégré ; les liens s'ouvrent dans le navigateur système." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "Ouvrir dans le navigateur intégré", "openSystem": "Ouvrir dans le navigateur système", "copy": "Copier le lien" + }, + "doc": { + "scriptsOff": "Activer les scripts", + "scriptsOn": "Scripts activés", + "scriptsHint": "Exécute les scripts de ce document. Ils ne peuvent lire que les fichiers de son dossier et n'ont pas accès au réseau. Si l'un de ces fichiers change ensuite, les scripts sont de nouveau désactivés.", + "reload": "Recharger", + "more": "Plus", + "copyPath": "Copier le chemin", + "useInline": "Utiliser l'aperçu en ligne", + "useGuest": "Utiliser l'aperçu du navigateur intégré", + "unsaved": "Les modifications non enregistrées ne sont pas affichées", + "reset": "{file} a changé après l'activation des scripts. Les scripts sont de nouveau désactivés.", + "enableAgain": "Réactiver", + "dismiss": "Fermer", + "externalLink": "Le lien vers {host} n'a pas été suivi", + "openLink": "Ouvrir le lien", + "downloadRefused": "Les téléchargements ne sont pas autorisés dans l'aperçu d'un document", + "openWithSystem": "Ouvrir avec l'application système", + "schemeBlocked": "{url} ne peut pas être ouvert ici", + "cannotShow": "Ce document ne peut pas être affiché" } } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fd84b569cd..4df8d1068c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "ルールはまだありません。", "terminalMenuTitle": "ターミナルのリンクメニュー", "terminalMenuHint": "ターミナル内のリンクをクリックすると、すぐに開く代わりに小さなメニュー(内蔵ブラウザ、システムブラウザ、リンクをコピー)を表示します。⌘/Ctrl クリックは引き続き直接開きます。", + "htmlPreviewTitle": "HTML ファイルのプレビュー", + "htmlPreviewHint": "HTML ファイルを内蔵ブラウザの制限付きドキュメントビューで表示します。ファイルごとに有効にするまでスクリプトは実行されず、そのフォルダーの外には到達できません。オフにすると、Web 版と同じインラインプレビューを使います。", "managedDisabled": "管理者のポリシーにより内蔵ブラウザは無効です。リンクはシステムブラウザで開きます。" }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "内蔵ブラウザで開く", "openSystem": "システムのブラウザで開く", "copy": "リンクをコピー" + }, + "doc": { + "scriptsOff": "スクリプトを有効にする", + "scriptsOn": "スクリプト有効", + "scriptsHint": "このドキュメントのスクリプトを実行します。スクリプトは同じフォルダー内のファイルしか読めず、ネットワークにはアクセスできません。その後いずれかのファイルが変更されると、スクリプトは再び無効になります。", + "reload": "再読み込み", + "more": "その他", + "copyPath": "パスをコピー", + "useInline": "インラインプレビューを使う", + "useGuest": "内蔵ブラウザのプレビューを使う", + "unsaved": "未保存の変更は表示されません", + "reset": "スクリプトを有効にした後に {file} が変更されました。スクリプトは再び無効になりました。", + "enableAgain": "再度有効にする", + "dismiss": "閉じる", + "externalLink": "{host} へのリンクは開きませんでした", + "openLink": "リンクを開く", + "downloadRefused": "ドキュメントプレビューではダウンロードできません", + "openWithSystem": "システムのアプリで開く", + "schemeBlocked": "{url} はここでは開けません", + "cannotShow": "このドキュメントを表示できません" } } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 125eb5a858..ab82b75c24 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "아직 규칙이 없습니다.", "terminalMenuTitle": "터미널 링크 메뉴", "terminalMenuHint": "터미널에서 링크를 클릭하면 바로 열지 않고 작은 메뉴(내장 브라우저, 시스템 브라우저, 링크 복사)를 표시합니다. ⌘/Ctrl 클릭은 여전히 바로 엽니다.", + "htmlPreviewTitle": "HTML 파일 미리보기", + "htmlPreviewHint": "HTML 파일을 내장 브라우저의 제한된 문서 보기로 표시합니다. 파일별로 켜기 전에는 스크립트가 실행되지 않고, 해당 폴더 밖에는 접근할 수 없습니다. 끄면 웹 앱과 같은 인라인 미리보기를 사용합니다.", "managedDisabled": "관리자 정책으로 내장 브라우저가 꺼져 있습니다. 링크는 시스템 브라우저에서 열립니다." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "내장 브라우저에서 열기", "openSystem": "시스템 브라우저에서 열기", "copy": "링크 복사" + }, + "doc": { + "scriptsOff": "스크립트 사용", + "scriptsOn": "스크립트 켜짐", + "scriptsHint": "이 문서의 스크립트를 실행합니다. 스크립트는 같은 폴더의 파일만 읽을 수 있고 네트워크에는 접근할 수 없습니다. 이후 그 파일 중 하나라도 바뀌면 스크립트는 다시 꺼집니다.", + "reload": "다시 불러오기", + "more": "더 보기", + "copyPath": "경로 복사", + "useInline": "인라인 미리보기 사용", + "useGuest": "내장 브라우저 미리보기 사용", + "unsaved": "저장하지 않은 변경 사항은 표시되지 않습니다", + "reset": "스크립트를 켠 뒤 {file}이(가) 변경되었습니다. 스크립트가 다시 꺼졌습니다.", + "enableAgain": "다시 사용", + "dismiss": "닫기", + "externalLink": "{host}(으)로 가는 링크를 열지 않았습니다", + "openLink": "링크 열기", + "downloadRefused": "문서 미리보기에서는 다운로드할 수 없습니다", + "openWithSystem": "시스템 앱으로 열기", + "schemeBlocked": "{url}은(는) 여기서 열 수 없습니다", + "cannotShow": "이 문서를 표시할 수 없습니다" } } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3885d12b45..17a9ab7a77 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "Ainda não há regras.", "terminalMenuTitle": "Menu de links do terminal", "terminalMenuHint": "Clicar em um link no terminal mostra um pequeno menu (navegador integrado, navegador do sistema, copiar link) em vez de abri-lo imediatamente. ⌘/Ctrl+clique continua abrindo o link diretamente.", + "htmlPreviewTitle": "Pré-visualização de arquivos HTML", + "htmlPreviewHint": "Mostra arquivos HTML pelo navegador integrado, em uma visualização de documento restrita: sem scripts até você ativá-los para um arquivo, e só a própria pasta é acessível. Desativado: usa a pré-visualização em linha, como no aplicativo web.", "managedDisabled": "A política do seu administrador desativou o navegador integrado; os links abrem no navegador do sistema." }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "Abrir no navegador integrado", "openSystem": "Abrir no navegador do sistema", "copy": "Copiar link" + }, + "doc": { + "scriptsOff": "Ativar scripts", + "scriptsOn": "Scripts ativados", + "scriptsHint": "Executa os scripts deste documento. Eles só podem ler arquivos da sua pasta e não têm acesso à rede. Se algum desses arquivos mudar depois, os scripts são desativados novamente.", + "reload": "Recarregar", + "more": "Mais", + "copyPath": "Copiar caminho", + "useInline": "Usar pré-visualização em linha", + "useGuest": "Usar a pré-visualização do navegador integrado", + "unsaved": "Alterações não salvas não são exibidas", + "reset": "{file} mudou depois que os scripts foram ativados. Os scripts foram desativados novamente.", + "enableAgain": "Ativar novamente", + "dismiss": "Fechar", + "externalLink": "O link para {host} não foi seguido", + "openLink": "Abrir link", + "downloadRefused": "Downloads não são permitidos na pré-visualização de um documento", + "openWithSystem": "Abrir com o aplicativo do sistema", + "schemeBlocked": "{url} não pode ser aberto aqui", + "cannotShow": "Este documento não pode ser exibido" } } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8e1b1fc4cb..dd70cb1fba 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "还没有规则。", "terminalMenuTitle": "终端链接菜单", "terminalMenuHint": "点击终端里的链接时先弹出一个小菜单(内置浏览器、系统浏览器、复制链接),而不是直接打开。⌘/Ctrl 点击仍会直接打开。", + "htmlPreviewTitle": "HTML 文件预览", + "htmlPreviewHint": "通过内置浏览器以受限的文档视图显示 HTML 文件:在你为某个文件启用之前不运行脚本,且只能访问它自己的文件夹。关闭后使用内联预览,与网页版相同。", "managedDisabled": "管理员策略已关闭内置浏览器;链接将在系统浏览器中打开。" }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "在内置浏览器中打开", "openSystem": "在系统浏览器中打开", "copy": "复制链接" + }, + "doc": { + "scriptsOff": "启用脚本", + "scriptsOn": "脚本已启用", + "scriptsHint": "运行此文档的脚本。脚本只能读取所在文件夹里的文件,无法访问网络。之后这些文件有任何变化,脚本会再次关闭。", + "reload": "重新加载", + "more": "更多", + "copyPath": "复制路径", + "useInline": "使用内联预览", + "useGuest": "使用内置浏览器预览", + "unsaved": "未保存的修改不会显示", + "reset": "{file} 在启用脚本后发生了变化,脚本已再次关闭。", + "enableAgain": "再次启用", + "dismiss": "关闭", + "externalLink": "未跟随指向 {host} 的链接", + "openLink": "打开链接", + "downloadRefused": "文档预览中不允许下载", + "openWithSystem": "用系统应用打开", + "schemeBlocked": "{url} 不能在这里打开", + "cannotShow": "无法显示此文档" } } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c5f0f9874c..9bb771854e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4795,6 +4795,8 @@ "rulesEmpty": "還沒有規則。", "terminalMenuTitle": "終端機連結選單", "terminalMenuHint": "點擊終端機中的連結時先顯示一個小選單(內建瀏覽器、系統瀏覽器、複製連結),而不是直接開啟。⌘/Ctrl 點擊仍會直接開啟。", + "htmlPreviewTitle": "HTML 檔案預覽", + "htmlPreviewHint": "透過內建瀏覽器以受限的文件檢視顯示 HTML 檔案:在你為某個檔案啟用之前不執行指令碼,且只能存取它自己的資料夾。關閉後使用內嵌預覽,與網頁版相同。", "managedDisabled": "管理員原則已關閉內建瀏覽器;連結將在系統瀏覽器中開啟。" }, "LogsSettings": { @@ -5921,6 +5923,26 @@ "openBuiltin": "在內建瀏覽器中開啟", "openSystem": "在系統瀏覽器中開啟", "copy": "複製連結" + }, + "doc": { + "scriptsOff": "啟用指令碼", + "scriptsOn": "指令碼已啟用", + "scriptsHint": "執行此文件的指令碼。指令碼只能讀取所在資料夾中的檔案,無法存取網路。之後這些檔案若有任何變更,指令碼會再次關閉。", + "reload": "重新載入", + "more": "更多", + "copyPath": "複製路徑", + "useInline": "使用內嵌預覽", + "useGuest": "使用內建瀏覽器預覽", + "unsaved": "未儲存的變更不會顯示", + "reset": "{file} 在啟用指令碼後發生了變更,指令碼已再次關閉。", + "enableAgain": "再次啟用", + "dismiss": "關閉", + "externalLink": "未跟隨指向 {host} 的連結", + "openLink": "開啟連結", + "downloadRefused": "文件預覽中不允許下載", + "openWithSystem": "用系統應用程式開啟", + "schemeBlocked": "{url} 無法在這裡開啟", + "cannotShow": "無法顯示此文件" } } } diff --git a/src/lib/browser/browser-api.ts b/src/lib/browser/browser-api.ts index 8fcc6703e7..efe2134785 100644 --- a/src/lib/browser/browser-api.ts +++ b/src/lib/browser/browser-api.ts @@ -11,6 +11,8 @@ import type { BrowserCapabilities, BrowserDownload, BrowserTabState, + DocGuestState, + DocMode, FrozenFrame, SurfaceChoice, } from "./types" @@ -25,6 +27,7 @@ const UNAVAILABLE: BrowserCapabilities = { proxy: { url: null, applies: "unsupported", reason: null }, downloadsDir: "", policy: { enabled: false, managedRules: [], managedSource: null }, + docGuest: false, } let capabilitiesPromise: Promise | null = null @@ -111,6 +114,55 @@ export function browserOpenTab( }) } +export interface OpenDocGuestParams { + tabId: string + /** Absolute path of the HTML file. */ + path: string + /** Directory to confine the document to (the owning workspace folder); + * null = the file's own directory. */ + root: string | null + bounds: Bounds + background?: boolean + devtools?: boolean +} + +export interface DocGuestOpenResult { + state: BrowserTabState + doc: DocGuestState +} + +/** Show a local HTML file through a document guest (see the Rust + * `doc_guest` module): an embedded surface registered like a tab's, whose + * requests the backend answers from the file's folder. */ +export function browserDocOpen( + params: OpenDocGuestParams +): Promise { + return getTransport().call("browser_doc_open", { + tabId: params.tabId, + path: params.path, + root: params.root, + bounds: params.bounds, + background: params.background ?? false, + devtools: params.devtools ?? false, + }) +} + +/** Switch a document guest between safe and dynamic mode; the guest reloads + * so the new policy travels with the document. */ +export function browserDocSetMode( + tabId: string, + mode: DocMode +): Promise { + return getTransport().call("browser_doc_set_mode", { + tabId, + mode, + }) +} + +export function browserDocState(tabId: string): Promise { + return getTransport().call("browser_doc_state", { tabId }) +} + export function browserClose(tabId: string): Promise { return getTransport().call("browser_close", { tabId }) } diff --git a/src/lib/browser/browser-prefs.test.ts b/src/lib/browser/browser-prefs.test.ts index 7b97058282..300d13baf9 100644 --- a/src/lib/browser/browser-prefs.test.ts +++ b/src/lib/browser/browser-prefs.test.ts @@ -9,6 +9,7 @@ import { setAllDefaultLinkTargets, setBrowserDevtools, setBrowserHostRules, + setBrowserHtmlPreviewEngine, setBrowserSurfaceOverride, setBrowserTerminalClickMenu, setDefaultLinkTarget, @@ -162,6 +163,16 @@ describe("browser prefs", () => { expect(getBrowserPrefs().terminalClickMenu).toBe(false) }) + it("defaults the HTML preview engine to the guest and stores only inline", () => { + expect(getBrowserPrefs().htmlPreviewEngine).toBe("guest") + setBrowserHtmlPreviewEngine("inline") + expect(localStorage.getItem("browser:html-preview-engine")).toBe("inline") + expect(getBrowserPrefs().htmlPreviewEngine).toBe("inline") + setBrowserHtmlPreviewEngine("guest") + expect(localStorage.getItem("browser:html-preview-engine")).toBeNull() + expect(getBrowserPrefs().htmlPreviewEngine).toBe("guest") + }) + it("useBrowserPrefs re-renders on change", () => { const { result } = renderHook(() => useBrowserPrefs()) expect(result.current.defaultTarget.toolCard).toBe("builtin") diff --git a/src/lib/browser/browser-prefs.ts b/src/lib/browser/browser-prefs.ts index 3365bc8f0c..498faa3a30 100644 --- a/src/lib/browser/browser-prefs.ts +++ b/src/lib/browser/browser-prefs.ts @@ -32,6 +32,11 @@ export type LinkTarget = "builtin" | "system" * back to the owned-window surface without a rebuild. */ export type SurfaceOverride = "auto" | "child" | "window" +/** What renders an HTML file's preview on the desktop: the document guest + * (a native surface served by the backend from the file's folder) or the + * inline `srcdoc` iframe the web build uses. */ +export type HtmlPreviewEngine = "guest" | "inline" + export interface BrowserPrefsSnapshot { defaultTarget: Readonly> devtools: boolean @@ -49,6 +54,9 @@ export interface BrowserPrefsSnapshot { * one more click on every link is only worth it to people who switch * destinations often; a modifier-click already offers the other one. */ terminalClickMenu: boolean + /** Guest by default wherever a guest exists; the inline preview remains + * one switch away for anyone who prefers the old rendering. */ + htmlPreviewEngine: HtmlPreviewEngine } export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ @@ -65,6 +73,7 @@ export const DEFAULT_BROWSER_PREFS: BrowserPrefsSnapshot = Object.freeze({ suspendBackgroundTabs: false, hostRules: Object.freeze([]) as readonly HostRule[], terminalClickMenu: false, + htmlPreviewEngine: "guest", }) as BrowserPrefsSnapshot const KEY_PREFIX = "browser:" @@ -81,6 +90,7 @@ const SUSPEND_KEY = `${KEY_PREFIX}suspend-background-tabs` // place, and half a table is not a meaningful state. const HOST_RULES_KEY = `${KEY_PREFIX}host-rules` const TERMINAL_MENU_KEY = `${KEY_PREFIX}terminal-click-menu` +const HTML_PREVIEW_KEY = `${KEY_PREFIX}html-preview-engine` function readRaw(key: string): string | null { if (typeof window === "undefined") return null @@ -130,6 +140,8 @@ function read(): BrowserPrefsSnapshot { suspendBackgroundTabs: readRaw(SUSPEND_KEY) === "true", hostRules: parseHostRules(readRaw(HOST_RULES_KEY)), terminalClickMenu: readRaw(TERMINAL_MENU_KEY) === "true", + htmlPreviewEngine: + readRaw(HTML_PREVIEW_KEY) === "inline" ? "inline" : "guest", } } @@ -193,6 +205,10 @@ export function setBrowserTerminalClickMenu(enabled: boolean): void { write(TERMINAL_MENU_KEY, enabled ? "true" : null) } +export function setBrowserHtmlPreviewEngine(engine: HtmlPreviewEngine): void { + write(HTML_PREVIEW_KEY, engine === "inline" ? "inline" : null) +} + export function subscribeBrowserPrefs(listener: () => void): () => void { if (typeof window === "undefined") return () => {} const onChange = () => listener() @@ -237,6 +253,7 @@ export function resetBrowserPrefsForTests(): void { localStorage.removeItem(SUSPEND_KEY) localStorage.removeItem(HOST_RULES_KEY) localStorage.removeItem(TERMINAL_MENU_KEY) + localStorage.removeItem(HTML_PREVIEW_KEY) } catch { /* ignore */ } diff --git a/src/lib/browser/browser-tab-persistence.test.ts b/src/lib/browser/browser-tab-persistence.test.ts index ce86a45eaf..83e07937a7 100644 --- a/src/lib/browser/browser-tab-persistence.test.ts +++ b/src/lib/browser/browser-tab-persistence.test.ts @@ -53,6 +53,7 @@ function state(over: Partial = {}): BrowserTabState { return { tabId: "abc", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/deep", diff --git a/src/lib/browser/browser-tab-store.doc.test.ts b/src/lib/browser/browser-tab-store.doc.test.ts new file mode 100644 index 0000000000..712cf3ef07 --- /dev/null +++ b/src/lib/browser/browser-tab-store.doc.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { + browserWorkspaceTabId, + getDocGuestState, + removeBrowserTabState, + resetBrowserTabStoreForTests, + setDocGuestState, + subscribeBrowserTabs, +} from "./browser-tab-store" +import type { DocGuestState } from "./types" + +function doc(overrides: Partial = {}): DocGuestState { + return { + tabId: "doc-1", + mode: "safe", + root: "/tmp/site", + entry: "/tmp/site/index.html", + url: "codeg-doc://doc/index.html", + reset: null, + ...overrides, + } +} + +describe("document guest state in the tab store", () => { + beforeEach(() => { + resetBrowserTabStoreForTests() + }) + + it("is keyed like the tab state and notifies only on a change", () => { + let notified = 0 + const unsubscribe = subscribeBrowserTabs(() => { + notified += 1 + }) + setDocGuestState(doc()) + expect(getDocGuestState(browserWorkspaceTabId("doc-1"))?.mode).toBe("safe") + expect(notified).toBe(1) + // The same payload again is not a change. + setDocGuestState(doc()) + expect(notified).toBe(1) + setDocGuestState(doc({ mode: "dynamic" })) + expect(getDocGuestState("browser:doc-1")?.mode).toBe("dynamic") + expect(notified).toBe(2) + unsubscribe() + }) + + it("goes away with the tab's state", () => { + setDocGuestState(doc({ reset: { path: "app.js", reason: "newer" } })) + removeBrowserTabState("browser:doc-1") + expect(getDocGuestState("browser:doc-1")).toBeNull() + }) +}) diff --git a/src/lib/browser/browser-tab-store.test.ts b/src/lib/browser/browser-tab-store.test.ts index 82a9f0904f..5c43b7ac45 100644 --- a/src/lib/browser/browser-tab-store.test.ts +++ b/src/lib/browser/browser-tab-store.test.ts @@ -32,6 +32,7 @@ function state(over: Partial = {}): BrowserTabState { return { tabId: "abc", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/", diff --git a/src/lib/browser/browser-tab-store.ts b/src/lib/browser/browser-tab-store.ts index fcaca096f0..043d2c80fd 100644 --- a/src/lib/browser/browser-tab-store.ts +++ b/src/lib/browser/browser-tab-store.ts @@ -9,7 +9,11 @@ import { useSyncExternalStore } from "react" import { browserClose } from "./browser-api" -import type { BrowserTabState, NavigationBlockReason } from "./types" +import type { + BrowserTabState, + DocGuestState, + NavigationBlockReason, +} from "./types" import { buildFileTabId } from "@/lib/file-tab-id" import { isDesktop } from "@/lib/transport" @@ -142,9 +146,39 @@ export function setBrowserTabState(state: BrowserTabState): void { export function removeBrowserTabState(workspaceTabId: string): void { const hadNotice = notices.delete(workspaceTabId) + const hadDoc = docStates.delete(workspaceTabId) hiddenAt.delete(workspaceTabId) findRequests.delete(workspaceTabId) - if (states.delete(workspaceTabId) || hadNotice) notify() + if (states.delete(workspaceTabId) || hadNotice || hadDoc) notify() +} + +// Mode and status of document guests (`browser://doc-state`), keyed like the +// tab state. Separate from it because it changes on its own schedule — the +// user's mode choice, a fall-back to safe mode — and carries no page state. +const docStates = new Map() + +export function setDocGuestState(doc: DocGuestState): void { + const key = browserWorkspaceTabId(doc.tabId) + const previous = docStates.get(key) + if (previous && JSON.stringify(previous) === JSON.stringify(doc)) return + docStates.set(key, doc) + notify() +} + +export function getDocGuestState(workspaceTabId: string): DocGuestState | null { + return docStates.get(workspaceTabId) ?? null +} + +/** Live document-guest state for one tab, or null for a tab that is not a + * document (or before its first `browser://doc-state`). */ +export function useDocGuestState( + workspaceTabId: string | null +): DocGuestState | null { + return useSyncExternalStore( + subscribeBrowserTabs, + () => (workspaceTabId ? (docStates.get(workspaceTabId) ?? null) : null), + getServerSnapshot + ) } export function subscribeBrowserTabs(listener: Listener): () => void { @@ -243,6 +277,7 @@ function getServerZero(): number { export function resetBrowserTabStoreForTests(): void { states.clear() notices.clear() + docStates.clear() listeners.clear() createdSurfaces.clear() surfaceOps.clear() diff --git a/src/lib/browser/types.test.ts b/src/lib/browser/types.test.ts index 33a7a18015..7bca080339 100644 --- a/src/lib/browser/types.test.ts +++ b/src/lib/browser/types.test.ts @@ -17,6 +17,7 @@ describe("browser wire types", () => { const state = { tabId: "t1", ownerWindow: "main", + kind: "page", surface: "child", channel: "native", url: "https://example.com/", @@ -49,6 +50,7 @@ describe("browser wire types", () => { reason: null, }, downloadsDir: "/Users/dev/Downloads", + docGuest: false, policy: { enabled: true, managedRules: [{ pattern: "*.internal.example", action: "block" }], diff --git a/src/lib/browser/types.ts b/src/lib/browser/types.ts index 6c6aa2bb06..a0f170d9d2 100644 --- a/src/lib/browser/types.ts +++ b/src/lib/browser/types.ts @@ -4,6 +4,11 @@ export type SurfaceKind = "child" | "window" +/** What a tab shows: a web page, or a local HTML file through the document + * guest (`codeg-doc:`), which the file column hosts in place of the inline + * HTML preview. */ +export type TabKind = "page" | "document" + export type ChannelKind = "native" | "degraded" | "legacy" /** Logical pixels, the unit `getBoundingClientRect()` reports. */ @@ -31,6 +36,7 @@ export interface BrowserErrorInfo { export interface BrowserTabState { tabId: string ownerWindow: string + kind: TabKind surface: SurfaceKind channel: ChannelKind /** Last committed URL ("" until the first document commits). */ @@ -94,6 +100,36 @@ export interface BrowserCapabilities { /** Absolute path a page's downloads land in. */ downloadsDir: string policy: BrowserPolicyStatus + /** Local HTML files can be shown through the document guest (an embedded + * surface with a handler for `codeg-doc:`; macOS for now). */ + docGuest: boolean +} + +/** How a document guest serves its file: as a picture of itself (no script, + * no connection), or with its own scripts running, confined to its folder. */ +export type DocMode = "safe" | "dynamic" + +export type DocResetReason = "newer" | "changed" + +/** Why a guest fell back to safe mode on its own: `path` (relative to the + * root) was found newer than the approval, or different from what had been + * served since it. */ +export interface DocReset { + path: string + reason: DocResetReason +} + +/** `browser://doc-state`: mode and status of one document guest. */ +export interface DocGuestState { + tabId: string + mode: DocMode + /** Absolute directory every request is confined to. */ + root: string + /** Absolute path of the document. */ + entry: string + /** The document's URL inside the guest. */ + url: string + reset: DocReset | null } /** The last frame of a page, returned by a hide-with-freeze: the placeholder @@ -166,8 +202,16 @@ export const BROWSER_TELEMETRY_EVENT = "browser://telemetry" export const BROWSER_DOWNLOAD_EVENT = "browser://download" export const BROWSER_SHORTCUT_EVENT = "browser://shortcut" export const BROWSER_NAVIGATION_BLOCKED_EVENT = "browser://navigation-blocked" - -export type NavigationBlockReason = "host-rule" | "scheme" +export const BROWSER_DOC_STATE_EVENT = "browser://doc-state" + +/** `external` and `download` come from document guests only: a web address + * the document pointed at (the user may open it in a tab), and a download + * it tried to start (documents do not download). */ +export type NavigationBlockReason = + | "host-rule" + | "scheme" + | "external" + | "download" /** `browser://navigation-blocked`: a top-level navigation a tab attempted * was refused by policy; the tab itself is unchanged. */ diff --git a/src/lib/browser/use-browser-capabilities.ts b/src/lib/browser/use-browser-capabilities.ts new file mode 100644 index 0000000000..b1d351696b --- /dev/null +++ b/src/lib/browser/use-browser-capabilities.ts @@ -0,0 +1,29 @@ +"use client" + +import { useEffect, useState } from "react" + +import { browserCapabilities, browserCapabilitiesSnapshot } from "./browser-api" +import type { BrowserCapabilities } from "./types" + +/** + * The backend's answer to `browser_capabilities`, for rendering decisions: + * the cached answer at once when it is in (the events bridge asks at + * startup), else null until the one round trip resolves. Web mode answers + * "unavailable" immediately. + */ +export function useBrowserCapabilities(): BrowserCapabilities | null { + const [capabilities, setCapabilities] = useState( + browserCapabilitiesSnapshot + ) + useEffect(() => { + if (capabilities) return + let cancelled = false + void browserCapabilities().then((answer) => { + if (!cancelled) setCapabilities(answer) + }) + return () => { + cancelled = true + } + }, [capabilities]) + return capabilities +} From 4005f85792cc370996faff5f0407fd28566ab8c8 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 8 Sep 2026 18:42:07 +0800 Subject: [PATCH 32/79] fix(browser): close the gaps review found in the document guest Guest (Rust): - Open the file along its canonical path component by component with O_NOFOLLOW, so a directory swapped for a symlink between the confinement check and the open is refused instead of followed (unix; the other platforms keep the final-component guard every confined reader has). - Key dynamic-mode pins by the requested path and fold the timestamps of every symlink the request traverses into the approval check, so a retargeted link (leaf or directory) is a change even when its new target predates the approval. - Decide the mode a response is served under together with the approval check, under one lock: a request that finds scripts switched off answers as safe mode, and a failed check ends dynamic mode in that same lock scope, so a newer approval cannot be the one cancelled. - A reset reloads every guest showing the document, not only the one whose request detected the change; the other guests' documents would otherwise keep their dynamic policy. - Document reloads (mode change, the reload button) navigate to the document when nothing has committed yet instead of going through the web retry path, which refuses codeg-doc: addresses. - Open reservations: an id is claimed before a surface is built and until the tab is registered, so two concurrent opens of one id cannot both build a surface and close each other's (pages and documents alike). Preview (frontend): - One backend id per mount, not per file tab: two previews of one file at once (the hidden file column behind a full-page route and the viewer drawer on it) are two guests, and a create that answers after its preview is gone lands under an id nobody uses. - A save that lands while the guest is still being created is applied as soon as the guest exists instead of being forgotten. - A click held for the terminal link menu no longer survives the terminal being re-initialised for another session. --- src-tauri/src/browser/doc_guest.rs | 317 +++++++++++++++--- src-tauri/src/browser/registry.rs | 78 ++++- src-tauri/src/browser/surface_child.rs | 24 +- src-tauri/src/commands/browser.rs | 100 ++++-- .../browser/browser-events-bridge.test.tsx | 1 + .../files/doc-guest-preview.test.tsx | 79 ++++- src/components/files/doc-guest-preview.tsx | 36 +- src/components/files/html-preview.test.tsx | 4 + .../terminal/terminal-link-menu.test.tsx | 9 + src/components/terminal/terminal-view.tsx | 4 + 10 files changed, 546 insertions(+), 106 deletions(-) diff --git a/src-tauri/src/browser/doc_guest.rs b/src-tauri/src/browser/doc_guest.rs index 66c842c9b7..94a5e3255a 100644 --- a/src-tauri/src/browser/doc_guest.rs +++ b/src-tauri/src/browser/doc_guest.rs @@ -12,17 +12,21 @@ //! grant (root + entry) and checks the webview id it is called for. //! - **Only files under the root.** Same rule as the inline preview: the root //! is the workspace folder the file sits in (else its own directory), the -//! path is canonicalized and confined, and the final component is opened -//! without following a symlink. No directory listings. +//! path is canonicalized and confined, and the file is then opened +//! component by component without following any symlink (a directory +//! swapped for a symlink after the check is refused, not followed). No +//! directory listings. //! - **Safe mode by default.** The document is served with a CSP that runs //! no script and opens no connection; images, styles and fonts come from //! the root only. **Dynamic mode** is a per-file, per-session decision by //! the user: scripts run, but still only from the root, and the only //! endpoint they can reach is the root (`connect-src 'self'`). //! - **What was approved is what runs.** Dynamic mode records when it was -//! granted; a file whose timestamps are newer than that, or whose content -//! differs from what was served since, drops the guest back to safe mode -//! and is not served. The document the user approved cannot be swapped +//! granted; a file whose timestamps (its own, or the symlink's it was +//! requested through) are newer than that, or whose content differs from +//! what was served under the same path since, drops the guest back to safe +//! mode and is not served — and every guest showing the document reloads +//! under the safe policy. The document the user approved cannot be swapped //! underneath the approval. //! - **A guest goes nowhere else.** Top-level navigation to a web address is //! refused and reported so the user can open it in a browser tab; @@ -291,7 +295,7 @@ impl DocGrant { let Some(rel) = request_rel_path(request.uri()) else { return Served::error(StatusCode::BAD_REQUEST, "not a document path", mode); }; - let (canonical, rel, mut file, metadata) = match self.open_confined(&rel) { + let (canonical, rel, links_changed, mut file, metadata) = match self.open_confined(&rel) { Ok(opened) => opened, Err(status) => { return Served::error(status, status.canonical_reason().unwrap_or("error"), mode) @@ -310,14 +314,13 @@ impl DocGrant { } drop(file); let rel_display = rel.to_string_lossy().replace('\\', "/"); - // Dynamic mode: the file must be the one that was approved. Checked - // and recorded under the lock, so two requests for a changed file - // cannot both pass by racing the pin. - if mode == DocMode::Dynamic { - if let Err(reset) = self.verify_approved(&rel_display, &canonical, &metadata, &bytes) { - let mut inner = self.lock(); - inner.approval = None; - inner.reset = Some(reset.clone()); + // The mode this response is served under is decided together with + // the approval check, under one lock: a request that started while + // scripts were on but finds them off answers as safe mode, and one + // that finds the file changed ends dynamic mode right there. + let mode = match self.approve(&rel_display, &rel, links_changed, &metadata, &bytes) { + Ok(mode) => mode, + Err(reset) => { let mut served = Served::error( StatusCode::FORBIDDEN, "file changed after scripts were enabled; the document is back in safe mode", @@ -326,7 +329,7 @@ impl DocGrant { served.reset = Some(reset); return served; } - } + }; let content_type = content_type(&canonical); let total = bytes.len() as u64; let mut builder = response_builder(mode).header(header::CONTENT_TYPE, content_type); @@ -367,10 +370,18 @@ impl DocGrant { } /// Resolve `rel` under the root, confined: canonicalized (so a symlink - /// cannot lead outside — a linked folder of the workspace is inside), - /// a directory answered by its `index.html`, and the final component - /// opened without following a symlink swapped in after the check. - fn open_confined(&self, rel: &Path) -> Result<(PathBuf, PathBuf, File, Metadata), StatusCode> { + /// cannot lead outside — a linked folder of the workspace is inside), a + /// directory answered by its `index.html`, and then opened along the + /// canonical path without following any symlink, so a component swapped + /// for a link after the check is refused rather than followed. Also + /// returns the newest change time of any symlink the REQUESTED path goes + /// through (a leaf or a directory link), which the approval check needs: + /// retargeting a link is a change of what the path names. + #[allow(clippy::type_complexity)] + fn open_confined( + &self, + rel: &Path, + ) -> Result<(PathBuf, PathBuf, Option, File, Metadata), StatusCode> { let mut rel = rel.to_path_buf(); let mut canonical = std::fs::canonicalize(self.root.join(&rel)).map_err(|_| StatusCode::NOT_FOUND)?; @@ -382,52 +393,147 @@ impl DocGrant { if !crate::commands::folders::is_within_workspace(&self.root, &canonical) { return Err(StatusCode::FORBIDDEN); } - let file = crate::commands::folders::open_no_follow(&canonical) - .map_err(|_| StatusCode::NOT_FOUND)?; + let links_changed = newest_link_change(&self.root, &rel); + let file = open_beneath(&canonical).map_err(|_| StatusCode::NOT_FOUND)?; let metadata = file.metadata().map_err(|_| StatusCode::NOT_FOUND)?; if !metadata.is_file() { return Err(StatusCode::NOT_FOUND); } - Ok((canonical, rel, file, metadata)) + Ok((canonical, rel, links_changed, file, metadata)) } - /// Dynamic mode's check: the file's timestamps predate the approval, and - /// its content is what was served since (pinned on first serve). - fn verify_approved( + /// Decide the mode a file is served under, and in dynamic mode check + /// that it is the file that was approved: its timestamps — and those of + /// the symlink it was requested through, if any — predate the approval, + /// and its content is what was served under the same requested path + /// since (pinned on first serve; keyed by the requested path, so a link + /// retargeted to another file is a change). A failed check ends dynamic + /// mode right here, under the same lock that checked, so a newer + /// approval taken meanwhile is not the one being cancelled. + fn approve( &self, rel_display: &str, - canonical: &Path, + rel: &Path, + links_changed: Option, metadata: &Metadata, bytes: &[u8], - ) -> Result<(), DocReset> { + ) -> Result { let mut inner = self.lock(); let Some(approval) = inner.approval.as_mut() else { - // Switched to safe mode while this request was in flight: serve - // it as safe mode would (the CSP already went out with the - // document; the next load is a safe one). - return Ok(()); + // Safe mode — including a request that started while scripts + // were on and finds them off: it answers under the safe policy. + return Ok(DocMode::Safe); }; - if newest_change(metadata) > approval.approved_at { - return Err(DocReset { - path: rel_display.to_string(), - reason: DocResetReason::Newer, - }); + let mut newest = newest_change(metadata); + if let Some(links) = links_changed { + newest = newest.max(links); } - let digest: [u8; 32] = Sha256::digest(bytes).into(); - match approval.pins.get(canonical) { - Some(pinned) if *pinned != digest => Err(DocReset { - path: rel_display.to_string(), - reason: DocResetReason::Changed, - }), - Some(_) => Ok(()), - None => { - approval.pins.insert(canonical.to_path_buf(), digest); - Ok(()) + let failed = if newest > approval.approved_at { + Some(DocResetReason::Newer) + } else { + let digest: [u8; 32] = Sha256::digest(bytes).into(); + match approval.pins.get(rel) { + Some(pinned) if *pinned != digest => Some(DocResetReason::Changed), + Some(_) => None, + None => { + approval.pins.insert(rel.to_path_buf(), digest); + None + } + } + }; + match failed { + None => Ok(DocMode::Dynamic), + Some(reason) => { + let reset = DocReset { + path: rel_display.to_string(), + reason, + }; + inner.approval = None; + inner.reset = Some(reset.clone()); + Err(reset) } } } } +/// Open a canonical path for reading without following any symlink along +/// the way: each component is opened relative to the previous one with +/// `O_NOFOLLOW` (directories with `O_DIRECTORY` as well), so a directory the +/// confinement check saw as real cannot be swapped for a link to somewhere +/// else between the check and the open. The path is canonical, so under +/// normal conditions no component is a link and the walk succeeds. +#[cfg(unix)] +fn open_beneath(canonical: &Path) -> std::io::Result { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + use std::path::Component; + + let mut components = canonical.components().peekable(); + if components.next() != Some(Component::RootDir) { + return Err(std::io::Error::other("document path is not absolute")); + } + // SAFETY: plain libc calls with checked arguments; every descriptor is + // owned by a `File` as soon as it is valid, so none leaks on an error. + let mut dir = unsafe { + let fd = libc::open(c"/".as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC); + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + File::from_raw_fd(fd) + }; + while let Some(component) = components.next() { + let Component::Normal(name) = component else { + return Err(std::io::Error::other("document path is not canonical")); + }; + let name = CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::other("NUL in document path"))?; + let last = components.peek().is_none(); + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_CLOEXEC + | if last { 0 } else { libc::O_DIRECTORY }; + // The parent stays open across the call and is closed right after, + // when `dir` is replaced. + let fd = unsafe { libc::openat(dir.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + dir = unsafe { File::from_raw_fd(fd) }; + } + Ok(dir) +} + +#[cfg(not(unix))] +fn open_beneath(canonical: &Path) -> std::io::Result { + // No `openat` here: the final component is protected (reparse points + // are not followed), the ancestors are the same residual every confined + // reader in this code base has on this platform. + crate::commands::folders::open_no_follow(canonical) +} + +/// The newest change time among the symlinks the requested path traverses +/// under the root — a leaf link or a directory link at any depth. A link is +/// recreated when it is retargeted, so its own timestamps say when the path +/// last changed what it names; plain directories are left out on purpose (a +/// directory's mtime moves for every unrelated file created next to the +/// document, which is no reason to distrust the document). +fn newest_link_change(root: &Path, rel: &Path) -> Option { + let mut newest = None; + let mut path = root.to_path_buf(); + for component in rel.components() { + path.push(component); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + break; + }; + if metadata.file_type().is_symlink() { + let changed = newest_change(&metadata); + newest = Some(newest.map_or(changed, |n: SystemTime| n.max(changed))); + } + } + newest +} + /// The last time the file changed by any account the filesystem keeps: its /// modification time and, where there is one, its status-change time (a /// rename into place bumps the latter and not the former). @@ -660,6 +766,19 @@ impl DocGuests { .get(tab_id) .cloned() } + + /// Every guest currently showing `grant`'s document. A reset applies to + /// all of them: one guest's document would otherwise keep its dynamic + /// policy and could still run what the others just refused. + pub fn tabs_of(&self, grant: &Arc) -> Vec { + self.by_tab + .lock() + .unwrap_or_else(|p| p.into_inner()) + .iter() + .filter(|(_, bound)| Arc::ptr_eq(bound, grant)) + .map(|(tab_id, _)| tab_id.clone()) + .collect() + } } #[cfg(test)] @@ -801,6 +920,98 @@ mod tests { assert_eq!(grant.mode(), DocMode::Safe); } + /// Pins are keyed by the path that was asked for: a link served once + /// and then pointed at another file is a change under that path, even + /// when the new target is older than the approval. + #[cfg(unix)] + #[test] + fn a_retargeted_link_is_a_change() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + let site = dir.path().join("site"); + write(&site, "old.js", b"old()"); + write(&site, "new.js", b"new()"); + // Both targets predate the approval by a wide margin. + for name in ["old.js", "new.js"] { + let file = File::options().write(true).open(site.join(name)).unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(3600)).unwrap(); + } + std::os::unix::fs::symlink(site.join("old.js"), site.join("alias.js")).unwrap(); + std::thread::sleep(Duration::from_millis(20)); + grant.set_mode(DocMode::Dynamic); + // The link itself was created just before the approval: fine. + // (Its own timestamps are those of the symlink, not of the target.) + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(get(&grant, "/alias.js").response.status(), StatusCode::OK); + std::fs::remove_file(site.join("alias.js")).unwrap(); + std::os::unix::fs::symlink(site.join("new.js"), site.join("alias.js")).unwrap(); + let served = get(&grant, "/alias.js"); + assert_eq!(served.response.status(), StatusCode::FORBIDDEN); + assert!(served.reset.is_some()); + assert_eq!(grant.mode(), DocMode::Safe); + } + + /// A directory link retargeted after the approval changes what every + /// path through it names: a file not yet pinned, older than the + /// approval, must still be refused. + #[cfg(unix)] + #[test] + fn a_retargeted_directory_link_is_a_change() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + let site = dir.path().join("site"); + write(&site, "v1/lazy.js", b"v1()"); + write(&site, "v2/lazy.js", b"v2()"); + for name in ["v1/lazy.js", "v2/lazy.js"] { + let file = File::options().write(true).open(site.join(name)).unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(3600)).unwrap(); + } + std::os::unix::fs::symlink(site.join("v1"), site.join("vendor")).unwrap(); + std::thread::sleep(Duration::from_millis(20)); + grant.set_mode(DocMode::Dynamic); + std::thread::sleep(Duration::from_millis(20)); + // Retarget the DIRECTORY link; `vendor/lazy.js` was never served. + std::fs::remove_file(site.join("vendor")).unwrap(); + std::os::unix::fs::symlink(site.join("v2"), site.join("vendor")).unwrap(); + let served = get(&grant, "/vendor/lazy.js"); + assert_eq!(served.response.status(), StatusCode::FORBIDDEN); + assert_eq!(served.reset.as_ref().map(|r| r.reason), Some(DocResetReason::Newer)); + assert_eq!(grant.mode(), DocMode::Safe); + } + + /// The component-wise open refuses a path that goes through a symlink, + /// which is what a directory swapped for a link after the confinement + /// check would look like. + #[cfg(unix)] + #[test] + fn open_beneath_refuses_symlinked_components() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real"); + std::fs::create_dir_all(&real).unwrap(); + write(&real, "a.txt", b"a"); + std::os::unix::fs::symlink(&real, dir.path().join("link")).unwrap(); + let canonical = std::fs::canonicalize(real.join("a.txt")).unwrap(); + assert!(open_beneath(&canonical).is_ok()); + let through_link = std::fs::canonicalize(dir.path()).unwrap().join("link/a.txt"); + assert!(open_beneath(&through_link).is_err()); + assert!(open_beneath(Path::new("relative/a.txt")).is_err()); + } + + /// A request that finds scripts switched off meanwhile answers as safe + /// mode: the policy in the response follows the decision, not the mode + /// the request started under. + #[test] + fn a_request_after_a_switch_to_safe_is_served_safe() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + grant.set_mode(DocMode::Dynamic); + grant.set_mode(DocMode::Safe); + let page = get(&grant, "/index.html"); + assert_eq!(page.response.status(), StatusCode::OK); + assert!(csp(&page).contains("script-src 'none'")); + assert!(page.reset.is_none()); + } + #[test] fn ranges_are_answered_in_bounded_pieces() { let dir = tempfile::tempdir().unwrap(); @@ -910,8 +1121,22 @@ mod tests { assert_eq!(second.mode(), DocMode::Dynamic); guests.bind("t1", first.clone()); assert!(guests.for_tab("t1").is_some()); + // Every guest of a document, for a reset that must reach them all. + guests.bind("t2", first.clone()); + let other = write(dir.path(), "b.html", b"y"); + let other = guests + .grant_for( + std::fs::canonicalize(dir.path()).unwrap(), + std::fs::canonicalize(other).unwrap(), + ) + .unwrap(); + guests.bind("t3", other); + let mut tabs = guests.tabs_of(&first); + tabs.sort(); + assert_eq!(tabs, vec!["t1".to_string(), "t2".to_string()]); guests.unbind("t1"); assert!(guests.for_tab("t1").is_none()); + assert_eq!(guests.tabs_of(&first), vec!["t2".to_string()]); assert_eq!(doc_label("t1"), "codeg-doc-t1"); } diff --git a/src-tauri/src/browser/registry.rs b/src-tauri/src/browser/registry.rs index 5b54639feb..61b72fed14 100644 --- a/src-tauri/src/browser/registry.rs +++ b/src-tauri/src/browser/registry.rs @@ -2,7 +2,7 @@ //! webview hooks and the window-close cleanup. The mutex is only ever held //! for map operations; every surface call happens on a clone taken out of it. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; @@ -87,9 +87,29 @@ impl BrowserTab { } } +/// The right to open a tab under an id, held from before its surface is +/// built until the tab is registered (`insert_reserved`) or the attempt is +/// abandoned (drop). Two opens of one id would otherwise both build a +/// surface and the loser, closing "its" surface, would close the winner's. +pub struct OpenReservation<'a> { + registry: &'a BrowserRegistry, + tab_id: String, + consumed: bool, +} + +impl Drop for OpenReservation<'_> { + fn drop(&mut self) { + if !self.consumed { + self.registry.release_opening(&self.tab_id); + } + } +} + #[derive(Default)] pub struct BrowserRegistry { tabs: Mutex>, + /// Ids whose open is under way (reserved, not yet inserted). + opening: Mutex>, /// One async lock per tab for `set_visible`: a hide that first captures /// a freeze frame spans an await, and the request behind it must not /// apply in between (tokio's mutex hands the lock out in arrival order). @@ -117,6 +137,50 @@ impl BrowserRegistry { .clone() } + fn release_opening(&self, tab_id: &str) { + self.opening + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(tab_id); + } + + /// Claim `tab_id` for an open that is about to build a surface. Fails + /// when a tab with that id exists or another open of it is under way. + /// Lock order: tabs, then opening. + pub fn reserve(&self, tab_id: &str) -> Result, AppCommandError> { + let tabs = self.lock(); + let mut opening = self + .opening + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if tabs.contains_key(tab_id) || !opening.insert(tab_id.to_string()) { + return Err(AppCommandError::already_exists(format!( + "browser tab {tab_id} is already open" + ))); + } + Ok(OpenReservation { + registry: self, + tab_id: tab_id.to_string(), + consumed: false, + }) + } + + /// Register a tab under the id its reservation holds. + pub fn insert_reserved( + &self, + tab: BrowserTab, + mut reservation: OpenReservation<'_>, + ) -> Result<(), AppCommandError> { + debug_assert_eq!(reservation.tab_id, tab.state.tab_id); + reservation.consumed = true; + let id = reservation.tab_id.clone(); + let result = self.insert(tab); + self.release_opening(&id); + result + } + + /// Register a tab whose id was not reserved (a popup adopted on the main + /// thread, whose id was minted there and checked against this map). pub fn insert(&self, mut tab: BrowserTab) -> Result<(), AppCommandError> { tab.generation = self.generations.fetch_add(1, Ordering::Relaxed) + 1; let mut tabs = self.lock(); @@ -346,6 +410,18 @@ mod tests { v } + /// One open at a time per id: the second reservation fails while the + /// first is held, and a dropped reservation frees the id again. + #[test] + fn open_reservations_are_exclusive_and_released_on_drop() { + let registry = BrowserRegistry::default(); + let first = registry.reserve("t1").expect("first reservation"); + assert!(registry.reserve("t1").is_err(), "second open of the same id must fail"); + assert!(registry.reserve("t2").is_ok(), "another id is unaffected"); + drop(first); + assert!(registry.reserve("t1").is_ok(), "released on drop"); + } + #[test] fn modifier_click_matching_rules() { let primary = cfg!(target_os = "macos"); diff --git a/src-tauri/src/browser/surface_child.rs b/src-tauri/src/browser/surface_child.rs index adaef07d9a..4d9f1e46a9 100644 --- a/src-tauri/src/browser/surface_child.rs +++ b/src-tauri/src/browser/surface_child.rs @@ -34,7 +34,7 @@ use tauri_runtime_wry::wry::{ }; use super::channel::{self, MessageSink}; -use super::doc_guest::{self, DocGrant, GuestNavigation}; +use super::doc_guest::{self, DocGrant, DocGuests, GuestNavigation}; #[cfg(target_os = "windows")] use super::profile; use super::events; @@ -623,17 +623,23 @@ fn document_protocol( events::emit_doc_state(&app, &grant.state(&tab_id)); } responder.respond(served.response); - // The document that is loading was served under the dynamic - // policy; reload it so the safe one applies to the whole page, - // not only to the file that was refused. Done here, not left to - // the frontend, so the fence holds with nobody watching. + // The documents that are loading were served under the dynamic + // policy; reload every guest of this grant so the safe one + // applies to the whole page everywhere, not only to the file + // that was refused in this guest. Done here, not left to the + // frontend, so the fence holds with nobody watching. if reset { - if let Some(registry) = app.try_state::() { - if let Some(surface) = registry.surface(&tab_id) { + let registry = app.try_state::(); + let guests = app.try_state::(); + if let (Some(registry), Some(guests)) = (registry, guests) { + for id in guests.tabs_of(&grant) { + let Some(surface) = registry.surface(&id) else { + continue; + }; if let Err(err) = surface.reload() { - tracing::warn!("[browser] document {tab_id}: reload after reset failed: {err}"); + tracing::warn!("[browser] document {id}: reload after reset failed: {err}"); } else { - hooks::begin_load(&app, &tab_id); + hooks::begin_load(&app, &id); } } } diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs index 6489319b9f..f540bd7fe9 100644 --- a/src-tauri/src/commands/browser.rs +++ b/src-tauri/src/commands/browser.rs @@ -162,12 +162,9 @@ pub fn open_tab_core( params: OpenTabParams, ) -> Result { validate_tab_id(¶ms.tab_id)?; - if registry.contains(¶ms.tab_id) { - return Err(AppCommandError::already_exists(format!( - "browser tab {} is already open", - params.tab_id - ))); - } + // Held until the tab is registered: a second open of the same id while + // this one builds its surface must fail, not build a second surface. + let reservation = registry.reserve(¶ms.tab_id)?; if app .try_state::() .is_some_and(|policy| !policy.enabled()) @@ -231,13 +228,16 @@ pub fn open_tab_core( remote_host: None, opener_tab_id: None, }; - if let Err(err) = registry.insert(BrowserTab::new( - state.clone(), - surface.clone(), - params.bounds, - !params.background, - params.devtools, - )) { + if let Err(err) = registry.insert_reserved( + BrowserTab::new( + state.clone(), + surface.clone(), + params.bounds, + !params.background, + params.devtools, + ), + reservation, + ) { let _ = surface.close(); return Err(err); } @@ -324,12 +324,7 @@ pub fn doc_open_core( params: DocOpenParams, ) -> Result { validate_tab_id(¶ms.tab_id)?; - if registry.contains(¶ms.tab_id) { - return Err(AppCommandError::already_exists(format!( - "browser tab {} is already open", - params.tab_id - ))); - } + let reservation = registry.reserve(¶ms.tab_id)?; if app .try_state::() .is_some_and(|policy| !policy.enabled()) @@ -368,7 +363,7 @@ pub fn doc_open_core( } #[cfg(not(all(feature = "browser-child", target_os = "macos")))] { - let _ = (owner, &label); + let _ = (owner, &label, reservation); return Err(AppCommandError::invalid_input( "document guests need the embedded browser surface (macOS for now)", )); @@ -394,13 +389,16 @@ pub fn doc_open_core( remote_host: None, opener_tab_id: None, }; - if let Err(err) = registry.insert(BrowserTab::new( - state.clone(), - surface.clone(), - params.bounds, - !params.background, - params.devtools, - )) { + if let Err(err) = registry.insert_reserved( + BrowserTab::new( + state.clone(), + surface.clone(), + params.bounds, + !params.background, + params.devtools, + ), + reservation, + ) { let _ = surface.close(); return Err(err); } @@ -453,13 +451,49 @@ pub fn doc_set_mode_core( let grant = guests .for_tab(tab_id) .ok_or_else(|| AppCommandError::not_found(format!("document guest {tab_id} not found")))?; + let surface = surface_of(registry, tab_id)?; grant.set_mode(mode); let doc = grant.state(tab_id); events::emit_doc_state(app, &doc); - reload_core(app, registry, tab_id)?; + reload_document(app, registry, tab_id, &surface, &grant.document_url())?; Ok(doc) } +/// Load a document guest's page again so the policy in force travels with +/// it. Nothing committed yet (the first load still in flight, or refused): +/// navigate to the document instead — a reload has nothing to reload, and +/// the general retry path would refuse a `codeg-doc:` address. +fn reload_document( + app: &AppHandle, + registry: &BrowserRegistry, + tab_id: &str, + surface: &BrowserSurface, + document_url: &str, +) -> Result<(), AppCommandError> { + let state = registry.update_state(tab_id, |state| { + state.requested_url = document_url.to_string(); + state.loading = true; + state.error = None; + }); + if surface.url().is_ok() { + surface + .reload() + .map_err(|e| window_err("Failed to reload the document", e))?; + } else { + let url = Url::parse(document_url).map_err(|e| { + AppCommandError::invalid_input(format!("bad document url {document_url:?}: {e}")) + })?; + surface + .navigate(url) + .map_err(|e| window_err("Failed to load the document", e))?; + } + hooks::begin_load(app, tab_id); + if let Some(state) = state { + events::emit_state(app, &state); + } + Ok(()) +} + pub fn doc_state_core(guests: &DocGuests, tab_id: &str) -> Result { guests .for_tab(tab_id) @@ -754,6 +788,16 @@ pub fn reload_core( let current = registry .state(tab_id) .ok_or_else(|| AppCommandError::not_found(format!("browser tab {tab_id} not found")))?; + // A document guest reloads its one document (its address is not a web + // address the retry path below would accept). + if current.kind == TabKind::Document { + let document_url = app + .try_state::() + .and_then(|guests| guests.for_tab(tab_id)) + .map(|grant| grant.document_url()) + .unwrap_or(current.requested_url); + return reload_document(app, registry, tab_id, &surface, &document_url); + } // Retry rather than reload when the page showing is not the one asked // for: a navigation that failed before committing left nothing to reload // (or left an older document, which the error page now covers), and the diff --git a/src/components/browser/browser-events-bridge.test.tsx b/src/components/browser/browser-events-bridge.test.tsx index 7d008b222d..effe4cb570 100644 --- a/src/components/browser/browser-events-bridge.test.tsx +++ b/src/components/browser/browser-events-bridge.test.tsx @@ -277,6 +277,7 @@ describe("BrowserEventsBridge", () => { unmount() expect(mocks.unsubscribed.sort()).toEqual([ "browser://closed", + "browser://doc-state", "browser://download", "browser://navigation-blocked", "browser://open-request", diff --git a/src/components/files/doc-guest-preview.test.tsx b/src/components/files/doc-guest-preview.test.tsx index 4a8b8e8a18..4d6bb1336f 100644 --- a/src/components/files/doc-guest-preview.test.tsx +++ b/src/components/files/doc-guest-preview.test.tsx @@ -219,6 +219,15 @@ describe("DocGuestPreview", () => { renderPreview() await flush() const id = openedId() + // Scripts on first, so the fall-back is a visible transition. + api.browserDocSetMode.mockImplementation((tabId: string, mode: string) => + Promise.resolve(docState(tabId, { mode: mode as "safe" | "dynamic" })) + ) + fireEvent.click(screen.getByRole("button", { name: "Enable scripts" })) + await flush() + expect( + screen.getByRole("button", { name: "Scripts on" }) + ).toBeInTheDocument() api.browserReload.mockClear() act(() => setDocGuestState( @@ -228,6 +237,10 @@ describe("DocGuestPreview", () => { }) ) ) + // The switch shows safe mode again. + expect( + screen.getByRole("button", { name: "Enable scripts" }) + ).toHaveAttribute("aria-pressed", "false") expect( screen.getByText( "app.js changed after scripts were enabled. Scripts are off again." @@ -235,13 +248,19 @@ describe("DocGuestPreview", () => { ).toBeInTheDocument() // The backend reloaded the document itself; the preview does not. expect(api.browserReload).not.toHaveBeenCalled() + // A fresh call, not the one that enabled scripts before the reset. + api.browserDocSetMode.mockClear() api.browserDocSetMode.mockImplementation((tabId: string) => Promise.resolve(docState(tabId, { mode: "dynamic" })) ) fireEvent.click(screen.getByRole("button", { name: "Enable again" })) await flush() + expect(api.browserDocSetMode).toHaveBeenCalledTimes(1) expect(api.browserDocSetMode).toHaveBeenCalledWith(id, "dynamic") expect(screen.queryByText(/changed after scripts/)).not.toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Scripts on" }) + ).toBeInTheDocument() }) it("offers to open a web link the document pointed at, through the app's link decision", async () => { @@ -324,7 +343,7 @@ describe("DocGuestPreview", () => { expect(api.browserReload).toHaveBeenCalledWith(id) }) - it("tears the guest down on unmount and reuses its id on the next mount", async () => { + it("tears the guest down on unmount; the next mount gets a guest of its own", async () => { const first = renderPreview() await flush() const id = openedId() @@ -335,7 +354,63 @@ describe("DocGuestPreview", () => { renderPreview() await flush() expect(api.browserDocOpen).toHaveBeenCalledTimes(2) - expect(openedId()).toBe(id) + expect(openedId()).not.toBe(id) + expect(api.browserClose).toHaveBeenCalledTimes(1) + }) + + it("gives two previews of one file two guests", async () => { + render( + + + + + ) + await flush() + expect(api.browserDocOpen).toHaveBeenCalledTimes(2) + const ids = api.browserDocOpen.mock.calls.map( + (call) => (call[0] as { tabId: string }).tabId + ) + expect(new Set(ids).size).toBe(2) + }) + + it("applies a save that lands while the guest is still being created", async () => { + let resolveOpen: ((value: unknown) => void) | null = null + api.browserDocOpen.mockImplementation( + ({ tabId }: { tabId: string }) => + new Promise((resolve) => { + resolveOpen = (value) => resolve(value) + void tabId + }) + ) + const { rerender } = renderPreview() + await flush() + const id = openedId() + expect(api.browserReload).not.toHaveBeenCalled() + rerender( + + saved

", savedContent: "

saved

" })} + rootPath="/tmp/site" + onUseInline={vi.fn()} + /> +
+ ) + // Nothing to reload yet, and the change is not forgotten. + expect(api.browserReload).not.toHaveBeenCalled() + await act(async () => { + resolveOpen?.({ state: tabState(id), doc: docState(id) }) + await Promise.resolve() + }) + await flush() + expect(api.browserReload).toHaveBeenCalledWith(id) }) it("offers the inline renderer from its menu", async () => { diff --git a/src/components/files/doc-guest-preview.tsx b/src/components/files/doc-guest-preview.tsx index 47ac8d5e63..993c662d8a 100644 --- a/src/components/files/doc-guest-preview.tsx +++ b/src/components/files/doc-guest-preview.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { useTranslations } from "next-intl" import { Copy, @@ -42,21 +42,6 @@ import { getAllowedExternalProtocol } from "@/lib/link-classify" import { openWithOsHandler } from "@/lib/link-open" import { cn, copyTextToClipboard } from "@/lib/utils" -// One backend id per file tab for the session. The guest is torn down -// whenever the file leaves the screen and created again when it returns — -// under the same id, so the store's per-id ordering of create and close -// applies across the two. -const backendIds = new Map() - -function backendIdFor(fileTabId: string): string { - let id = backendIds.get(fileTabId) - if (!id) { - id = `doc-${crypto.randomUUID()}` - backendIds.set(fileTabId, id) - } - return id -} - function dirname(path: string): string { const cut = path.replace(/[\\/]+$/, "") const at = Math.max(cut.lastIndexOf("/"), cut.lastIndexOf("\\")) @@ -98,7 +83,15 @@ export function DocGuestPreview({ }) { const t = useTranslations("Browser.doc") const path = tab.path ?? "" - const backendId = useMemo(() => backendIdFor(tab.id), [tab.id]) + // One backend id per MOUNT. The guest is torn down when its preview + // unmounts and created afresh when a preview mounts again, so two previews + // of one file at the same time — the file column kept mounted (hidden) + // behind a full-page route and the viewer drawer on that route — are two + // guests, each fitted to its own placeholder and each with its own state, + // sharing nothing but the document's grant on the backend. A fresh id per + // mount also means a create that answers after its preview is gone lands + // under an id nobody is looking at. + const [backendId] = useState(() => `doc-${crypto.randomUUID()}`) const storeKey = browserWorkspaceTabId(backendId) const state = useBrowserTabState(storeKey) const doc = useDocGuestState(storeKey) @@ -124,13 +117,16 @@ export function DocGuestPreview({ ) // The file on disk changed under the guest — a save from the editor, an - // external change the watcher picked up: show the new one. The first value - // is the one the guest loaded. + // external change the watcher picked up: show the new one. The value at + // mount is what the guest is being created from; a change is only + // consumed once there is a guest to reload, so a save that lands while + // the guest is still being created is applied as soon as it exists. const savedRef = useRef(tab.savedContent) useEffect(() => { + if (!state) return if (savedRef.current === tab.savedContent) return savedRef.current = tab.savedContent - if (state) void browserReload(backendId).catch(() => {}) + void browserReload(backendId).catch(() => {}) }, [backendId, state, tab.savedContent]) // When the backend drops the guest back to safe mode (a served file diff --git a/src/components/files/html-preview.test.tsx b/src/components/files/html-preview.test.tsx index 11d7258a44..e406bc17f9 100644 --- a/src/components/files/html-preview.test.tsx +++ b/src/components/files/html-preview.test.tsx @@ -105,6 +105,10 @@ describe("HtmlPreview engine choice", () => { renderPreview() await flush() expect(screen.getByTitle("HTML preview")).toBeInTheDocument() + expect(screen.queryByTestId("doc-guest")).not.toBeInTheDocument() + expect( + screen.queryByLabelText("Use built-in browser preview") + ).not.toBeInTheDocument() }) it("follows the setting, and a per-file choice made from the preview overrides it", async () => { diff --git a/src/components/terminal/terminal-link-menu.test.tsx b/src/components/terminal/terminal-link-menu.test.tsx index 030727c8a4..e50e67ee8a 100644 --- a/src/components/terminal/terminal-link-menu.test.tsx +++ b/src/components/terminal/terminal-link-menu.test.tsx @@ -90,6 +90,15 @@ describe("TerminalLinkMenu", () => { expect(onChoose).toHaveBeenCalledWith("https://example.com/a", "builtin") }) + it("closes after a choice, so the caller can hand focus back", () => { + const { onChoose, onClose } = renderMenu({}) + fireEvent.click( + screen.getByRole("menuitem", { name: "Open in system browser" }) + ) + expect(onChoose).toHaveBeenCalledWith("https://example.com/a", "system") + expect(onClose).toHaveBeenCalled() + }) + it("chooses the system browser explicitly", () => { const { onChoose } = renderMenu({}) fireEvent.click( diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 4af2c30246..a14a535147 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -590,6 +590,10 @@ export function TerminalView({ return () => { cancelled = true cleanup?.() + // A click held for the menu belongs to the terminal that was clicked; + // when this effect re-runs for another terminal (or unmounts) the + // menu must not outlive it and open the old terminal's link. + setLinkClick(null) } }, [terminalId, workingDir, shell, initialCommand, attach]) From 80e1e9e35c92195afcc7a566fb3ffb2d1eb7992e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 8 Sep 2026 19:00:19 +0800 Subject: [PATCH 33/79] fix(browser): resolve every link on a document path before approving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval check now walks the requested path the way the filesystem does, following each symlink it meets — including the links a link's target goes through — and folds every traversed link's change time into the "newer than the approval" test, so retargeting a link anywhere in the chain is a change. A path that cannot be walked is refused rather than served on the strength of the canonicalization alone, and the file the walk ends at must be the very file that was opened (device and inode). --- src-tauri/src/browser/doc_guest.rs | 159 +++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/browser/doc_guest.rs b/src-tauri/src/browser/doc_guest.rs index 94a5e3255a..690ad52087 100644 --- a/src-tauri/src/browser/doc_guest.rs +++ b/src-tauri/src/browser/doc_guest.rs @@ -393,10 +393,15 @@ impl DocGrant { if !crate::commands::folders::is_within_workspace(&self.root, &canonical) { return Err(StatusCode::FORBIDDEN); } - let links_changed = newest_link_change(&self.root, &rel); + // Every symlink on the way — lexical components and the links their + // targets go through — and the file the walk ends at. A path that + // cannot be walked (a component gone, a loop) is refused, never + // served on the strength of the canonicalization alone. + let (walked, links_changed) = + walk_links(&self.root, &rel).map_err(|_| StatusCode::NOT_FOUND)?; let file = open_beneath(&canonical).map_err(|_| StatusCode::NOT_FOUND)?; let metadata = file.metadata().map_err(|_| StatusCode::NOT_FOUND)?; - if !metadata.is_file() { + if !metadata.is_file() || !same_file(&walked, &metadata) { return Err(StatusCode::NOT_FOUND); } Ok((canonical, rel, links_changed, file, metadata)) @@ -512,26 +517,85 @@ fn open_beneath(canonical: &Path) -> std::io::Result { crate::commands::folders::open_no_follow(canonical) } -/// The newest change time among the symlinks the requested path traverses -/// under the root — a leaf link or a directory link at any depth. A link is -/// recreated when it is retargeted, so its own timestamps say when the path -/// last changed what it names; plain directories are left out on purpose (a -/// directory's mtime moves for every unrelated file created next to the -/// document, which is no reason to distrust the document). -fn newest_link_change(root: &Path, rel: &Path) -> Option { - let mut newest = None; - let mut path = root.to_path_buf(); - for component in rel.components() { - path.push(component); - let Ok(metadata) = std::fs::symlink_metadata(&path) else { - break; - }; - if metadata.file_type().is_symlink() { - let changed = newest_change(&metadata); - newest = Some(newest.map_or(changed, |n: SystemTime| n.max(changed))); +/// Most links a resolution may go through before it counts as a loop +/// (what the C library allows for `realpath`). +const MAX_LINK_HOPS: usize = 40; + +/// Resolve `rel` under `root` the way the filesystem does — component by +/// component, following every symlink met on the way, including the links a +/// link's target goes through — and report where it ends together with the +/// newest change time of every symlink traversed. A link is recreated when +/// it is retargeted, so its own timestamps say when the path last changed +/// what it names; plain directories are left out on purpose (a directory's +/// mtime moves for every unrelated file created next to the document, which +/// is no reason to distrust the document). Any component that cannot be +/// read is an error: the caller refuses rather than serves. +fn walk_links(root: &Path, rel: &Path) -> std::io::Result<(PathBuf, Option)> { + use std::collections::VecDeque; + use std::ffi::OsString; + use std::path::Component; + + let mut newest: Option = None; + let mut current = root.to_path_buf(); + let mut pending: VecDeque = rel + .components() + .map(|c| c.as_os_str().to_os_string()) + .collect(); + let mut hops = 0; + while let Some(component) = pending.pop_front() { + if component == "." { + continue; + } + if component == ".." { + current.pop(); + continue; + } + let candidate = current.join(&component); + let metadata = std::fs::symlink_metadata(&candidate)?; + if !metadata.file_type().is_symlink() { + current = candidate; + continue; + } + hops += 1; + if hops > MAX_LINK_HOPS { + return Err(std::io::Error::other("too many symbolic links")); + } + let changed = newest_change(&metadata); + newest = Some(newest.map_or(changed, |n| n.max(changed))); + let target = std::fs::read_link(&candidate)?; + // The target's components go in front of what is left to walk; an + // absolute target starts the walk over from its root. + let mut spliced: VecDeque = VecDeque::new(); + for part in target.components() { + match part { + Component::Prefix(prefix) => current = PathBuf::from(prefix.as_os_str()), + Component::RootDir => current.push(std::path::MAIN_SEPARATOR.to_string()), + Component::CurDir => {} + Component::ParentDir => spliced.push_back(OsString::from("..")), + Component::Normal(name) => spliced.push_back(name.to_os_string()), + } } + spliced.extend(pending.drain(..)); + pending = spliced; } - newest + Ok((current, newest)) +} + +/// Whether the walk and the open landed on the same file (device and inode +/// on unix). A link changed between the two would make them differ, and the +/// request is refused. Where identity cannot be checked the walk's own +/// success is what stands. +#[cfg(unix)] +fn same_file(walked: &Path, opened: &Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + std::fs::symlink_metadata(walked) + .map(|m| m.dev() == opened.dev() && m.ino() == opened.ino()) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn same_file(_walked: &Path, _opened: &Metadata) -> bool { + true } /// The last time the file changed by any account the filesystem keeps: its @@ -979,6 +1043,61 @@ mod tests { assert_eq!(grant.mode(), DocMode::Safe); } + /// A link reached through another link's target is part of the path as + /// much as a lexical component: retargeting it is a change too. + #[cfg(unix)] + #[test] + fn a_retargeted_link_behind_a_link_is_a_change() { + let dir = tempfile::tempdir().unwrap(); + let grant = grant_in(dir.path()); + let site = dir.path().join("site"); + write(&site, "v1/lazy.js", b"v1()"); + write(&site, "v2/lazy.js", b"v2()"); + for name in ["v1/lazy.js", "v2/lazy.js"] { + let file = File::options().write(true).open(site.join(name)).unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(3600)).unwrap(); + } + // `assets -> linked` (relative), `linked -> v1` (absolute). + std::os::unix::fs::symlink("linked", site.join("assets")).unwrap(); + std::os::unix::fs::symlink(site.join("v1"), site.join("linked")).unwrap(); + std::thread::sleep(Duration::from_millis(20)); + grant.set_mode(DocMode::Dynamic); + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(get(&grant, "/assets/lazy.js").response.status(), StatusCode::OK); + assert_eq!(get(&grant, "/assets/lazy.js").response.body(), b"v1()"); + std::fs::remove_file(site.join("linked")).unwrap(); + std::os::unix::fs::symlink(site.join("v2"), site.join("linked")).unwrap(); + let served = get(&grant, "/assets/lazy.js"); + assert_eq!(served.response.status(), StatusCode::FORBIDDEN); + assert_eq!(grant.mode(), DocMode::Safe); + } + + /// The link walk resolves like the filesystem (relative targets, `..`, + /// absolute targets) and refuses what it cannot read. + #[cfg(unix)] + #[test] + fn the_link_walk_resolves_like_realpath_and_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + write(&root, "real/deep/file.txt", b"x"); + std::os::unix::fs::symlink("../real/deep", root.join("other/hop")).unwrap_or_else(|_| { + std::fs::create_dir_all(root.join("other")).unwrap(); + std::os::unix::fs::symlink("../real/deep", root.join("other/hop")).unwrap(); + }); + std::os::unix::fs::symlink(root.join("other"), root.join("abs")).unwrap(); + let (walked, newest) = walk_links(&root, Path::new("abs/hop/file.txt")).unwrap(); + assert_eq!(walked, root.join("real/deep/file.txt")); + assert!(newest.is_some()); + let (plain, none) = walk_links(&root, Path::new("real/deep/file.txt")).unwrap(); + assert_eq!(plain, root.join("real/deep/file.txt")); + assert!(none.is_none()); + assert!(walk_links(&root, Path::new("abs/hop/missing.txt")).is_err()); + assert!(walk_links(&root, Path::new("gone/file.txt")).is_err()); + std::os::unix::fs::symlink("loop-b", root.join("loop-a")).unwrap(); + std::os::unix::fs::symlink("loop-a", root.join("loop-b")).unwrap(); + assert!(walk_links(&root, Path::new("loop-a")).is_err()); + } + /// The component-wise open refuses a path that goes through a symlink, /// which is what a directory swapped for a link after the confinement /// check would look like. From 3c64454c2ad0946510a21047ac2c961a701c759a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 8 Sep 2026 20:24:54 +0800 Subject: [PATCH 34/79] feat(browser): bridge dev-server ports into web-mode browser tabs In a browser session (codeg-server / Docker) a link to http://localhost:3000 meant the server's loopback, which the user's browser cannot reach, so every such link opened a dead tab. The server now binds one extra port per dev server (the ten ports after its own, or CODEG_BRIDGE_PORTS) and forwards it to 127.0.0.1:; the workbench opens the page as a browser tab whose body is an iframe on that bridge port, with reload, open-in-a-new-tab and copy-address. One listener per target port instead of a shared listener with a path prefix: an opaque-origin frame sends no Referer and attaches no cookie to module scripts, fetch or XHR, so root-absolute URLs (/src/main.tsx, /_next/..., /@vite/client) could not be routed to a port without rewriting every body. With its own origin per port the frame may keep allow-same-origin, nothing is rewritten, client-side routers and HMR websockets work as on the host. Access: the token-authenticated API mints a per-tab capability; the frame loads the listener's entry URL, which sets an HttpOnly SameSite=Lax cookie named after the bridge port and redirects to the page. A different port on the same host is the same site, so browsers (Safari included) treat the cookie as first-party inside the frame; codeg's own auth never reads cookies. Requests reach the dev server with Origin, Referer and Host naming the target, without the bridge's or the workbench's cookies; X-Frame-Options is dropped, absolute Location headers on the target become paths. A listener closes a minute after its last tab releases it, or after two hours without a request. Only plain http to the server's loopback is bridged; codeg's own port and everything else is refused. The link decision gains a terminal rule: in a browser with the bridge enabled, a loopback http address opens built-in and cannot be inverted to the system browser. Markdown document previews now route web links through the same decision (editor source) instead of the system browser. Docker files publish the default range. --- Dockerfile | 2 + docker-compose.yml | 6 + src-tauri/src/bin/codeg_server.rs | 12 + src-tauri/src/web/browser_bridge.rs | 1070 +++++++++++++++++ src-tauri/src/web/handlers/browser_bridge.rs | 120 ++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/mod.rs | 31 + src-tauri/src/web/router.rs | 13 + src-tauri/tests/browser_bridge.rs | 440 +++++++ src-tauri/tests/browser_bridge_idle.rs | 84 ++ .../browser/browser-bridge-view.test.tsx | 199 +++ .../browser/browser-bridge-view.tsx | 221 ++++ .../browser/browser-events-bridge.tsx | 6 +- src/components/browser/browser-tab-view.tsx | 14 +- .../files/markdown-document-preview.tsx | 21 +- src/hooks/use-open-url-target.ts | 18 +- src/i18n/messages/ar.json | 11 + src/i18n/messages/de.json | 11 + src/i18n/messages/en.json | 11 + src/i18n/messages/es.json | 11 + src/i18n/messages/fr.json | 11 + src/i18n/messages/ja.json | 11 + src/i18n/messages/ko.json | 11 + src/i18n/messages/pt.json | 11 + src/i18n/messages/zh-CN.json | 11 + src/i18n/messages/zh-TW.json | 11 + src/lib/browser/browser-bridge.test.ts | 173 +++ src/lib/browser/browser-bridge.ts | 143 +++ src/lib/resolve-link-action.test.ts | 90 ++ src/lib/resolve-link-action.ts | 34 +- 30 files changed, 2794 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/web/browser_bridge.rs create mode 100644 src-tauri/src/web/handlers/browser_bridge.rs create mode 100644 src-tauri/tests/browser_bridge.rs create mode 100644 src-tauri/tests/browser_bridge_idle.rs create mode 100644 src/components/browser/browser-bridge-view.test.tsx create mode 100644 src/components/browser/browser-bridge-view.tsx create mode 100644 src/lib/browser/browser-bridge.test.ts create mode 100644 src/lib/browser/browser-bridge.ts diff --git a/Dockerfile b/Dockerfile index 3a53a20365..3565ba8596 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,8 @@ ENV CODEG_RUNTIME=docker ENV CODEG_RESTART_DELAY_MS=2000 EXPOSE 3080 +# Port bridge for dev servers (CODEG_BRIDGE_PORTS; default CODEG_PORT+1..+10). +EXPOSE 3081-3090 VOLUME /data # Run under the built-in supervisor (PID 1) so an in-place upgrade can swap diff --git a/docker-compose.yml b/docker-compose.yml index ce44068c1d..7c396bda5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ services: # image: xintaofei/codeg:latest ports: - "3080:3080" + # Port bridge for dev servers started by agents inside the container: + # the workbench shows `http://localhost:3000` from the container through + # one of these ports (one per dev server). Publish the same range you set + # in CODEG_BRIDGE_PORTS (default: the ten ports after CODEG_PORT). + - "3081-3090:3081-3090" volumes: - codeg-data:/data # Mount your project directories (optional): @@ -13,6 +18,7 @@ services: - CODEG_TOKEN=${CODEG_TOKEN:-} - CODEG_PORT=3080 - CODEG_HOST=0.0.0.0 + # - CODEG_BRIDGE_PORTS=3081-3090 # or `off` to disable the port bridge # In-place upgrades (Settings → Software Update) rewrite the binaries and # web assets inside this container's writable layer, not the image. The # codeg-data volume persists, but an upgrade lives only in the running diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 71c5052b5e..3f2d932502 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -585,6 +585,18 @@ async fn async_main() -> ExitCode { // Token on stderr ONLY (bearer credential — keep it out of the log files // and the in-app viewer); the bind addresses are safe to log normally. eprintln!("[SERVER] Token: {}", token); + // Port bridge for dev servers on this host (web-mode built-in browser): + // the ports after ours unless CODEG_BRIDGE_PORTS says otherwise. + let bridge = codeg_lib::web::browser_bridge::BridgeConfig::from_env(&host, actual_port); + match &bridge { + Some(config) => tracing::info!( + "[SERVER] Port bridge for dev servers: ports {} (CODEG_BRIDGE_PORTS)", + codeg_lib::web::describe_ports(&config.ports) + ), + None => tracing::info!("[SERVER] Port bridge for dev servers: off (CODEG_BRIDGE_PORTS)"), + } + codeg_lib::web::browser_bridge::configure(bridge); + tracing::info!("[SERVER] Listening on:"); for addr in &addresses { tracing::info!(" {}", addr); diff --git a/src-tauri/src/web/browser_bridge.rs b/src-tauri/src/web/browser_bridge.rs new file mode 100644 index 0000000000..4ec0b81e37 --- /dev/null +++ b/src-tauri/src/web/browser_bridge.rs @@ -0,0 +1,1070 @@ +//! Web-mode port bridge: shows a dev server that runs on the codeg host +//! inside the workbench when the workbench itself runs in a browser. +//! +//! In server / Docker deployments an agent's `http://localhost:3000` means the +//! *server's* loopback, which the user's browser cannot reach. The bridge +//! listens on extra ports next to codeg's own and forwards each of them to one +//! loopback port on the host, so the workbench can put the page in an iframe. +//! +//! ## One listener per target port — why not one shared listener +//! +//! A page served through a shared listener under a path prefix +//! (`/{cap}/{port}/…`) breaks as soon as it uses root-absolute URLs, which +//! every module-based dev server does (`/src/main.tsx`, `/_next/…`, +//! `/@vite/client`). Those requests arrive without the prefix and nothing in +//! them says which port they belong to: an iframe without `allow-same-origin` +//! runs in an opaque origin, which sends no `Referer` and attaches no cookies +//! to module scripts, `fetch` or XHR. Giving the frame `allow-same-origin` +//! would let two proxied pages read each other. So each target port gets its +//! own origin (its own bridge port), the frame may keep its origin, and the +//! page is served at `/` exactly as it would be on the host: no rewriting of +//! bodies, `history.pushState` routers work, HMR websockets connect. +//! +//! ## Authentication: a same-site cookie, not codeg's token +//! +//! An iframe navigation cannot carry a bearer header, and the page's own +//! requests must not carry codeg's token either. The workbench asks the API +//! (with the token) for a grant; the grant is a random capability tied to one +//! listener. The frame first loads the listener's entry URL carrying that +//! capability, which sets an `HttpOnly; SameSite=Lax` cookie named after the +//! bridge port and redirects to the page. Every later request — documents, +//! modules, fetch, websocket upgrades — carries the cookie: the bridge is a +//! different port on the same host as the workbench, which is the same *site*, +//! so browsers treat those cookies as first-party (including Safari). The +//! cookie is sent to codeg's own port too, where nothing reads cookies; the +//! workbench's own cookies (locale preferences) reach the bridge the same way +//! and are stripped before a request goes on to the dev server. +//! +//! A capability stays valid for the life of its listener. A listener lives +//! while a workbench tab holds it, closes a minute after the last hold is +//! released, and closes after two hours without a request even when held (a +//! browser tab closed without notice); reopening the page mints a new grant. + +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::extract::ws::{CloseFrame, Message as DownMessage, WebSocket, WebSocketUpgrade}; +use axum::extract::{FromRequestParts, Path as AxumPath, RawQuery, Request, State}; +use axum::http::request::Parts; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, get}; +use axum::Router; +use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::CloseFrame as UpCloseFrame; +use tokio_tungstenite::tungstenite::Message as UpMessage; + +/// Entry URL prefix on a bridge listener: `/__codeg_bridge/enter/{cap}?to=/path`. +pub const ENTER_PREFIX: &str = "/__codeg_bridge/enter/"; +/// Unauthenticated reachability probe the workbench calls before showing +/// the frame, so an unmapped port is reported instead of a blank frame. +pub const PING_PATH: &str = "/__codeg_bridge/ping"; +const COOKIE_PREFIX: &str = "codeg-bridge-"; +/// The workbench's own cookies (locale preferences) live on the same host +/// and are not the dev server's business either. +const WORKBENCH_COOKIE_PREFIX: &str = "codeg."; +/// Ports above codeg's own that the bridge takes when `CODEG_BRIDGE_PORTS` +/// is not set. +pub const DEFAULT_POOL_SIZE: u16 = 10; +/// A listener nobody holds closes after this long without a request. +const UNHELD_IDLE: Duration = Duration::from_secs(60); +/// A held listener closes after this long without a request. +const HELD_IDLE: Duration = Duration::from_secs(2 * 60 * 60); +pub const SWEEP_INTERVAL: Duration = Duration::from_secs(30); +/// How long a closing listener waits for its connections before they are cut. +const CLOSE_GRACE: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeConfig { + /// Address the listeners bind to: the same one codeg's API listener uses. + pub bind_host: String, + /// Ports a listener may take, in order of preference; `0` means any free + /// port (each listener its own). + pub ports: Vec, + /// Hostname the browser should use for the bridge when it differs from + /// the one the workbench was loaded from (a reverse proxy in front). + pub public_host: Option, + /// Ports the bridge refuses to forward to: codeg's own listener. + pub reserved: Vec, +} + +impl BridgeConfig { + /// Read `CODEG_BRIDGE_PORTS` / `CODEG_BRIDGE_PUBLIC_HOST`. `None` when the + /// bridge is switched off (`CODEG_BRIDGE_PORTS=off`). + pub fn from_env(bind_host: &str, codeg_port: u16) -> Option { + let ports = match std::env::var("CODEG_BRIDGE_PORTS") { + Ok(raw) => parse_ports(&raw, codeg_port)?, + Err(_) => default_ports(codeg_port), + }; + let public_host = std::env::var("CODEG_BRIDGE_PUBLIC_HOST") + .ok() + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()); + Some(Self { + bind_host: bind_host.to_string(), + ports, + public_host, + reserved: vec![codeg_port], + }) + } +} + +/// The ten ports after codeg's own, stopping at the end of the port space. +pub fn default_ports(codeg_port: u16) -> Vec { + (1..=DEFAULT_POOL_SIZE) + .filter_map(|offset| codeg_port.checked_add(offset)) + .collect() +} + +/// `3081-3090`, `3081,3082,3090`, a mix of both, `auto` (any free port), or +/// `off` / `none` / `disabled` / empty (no bridge; returns `None`). Codeg's +/// own port is never part of the pool. An unparseable value is `None` too: +/// a typo must not silently bind ten ports the operator did not choose. +pub fn parse_ports(raw: &str, codeg_port: u16) -> Option> { + let raw = raw.trim(); + if raw.is_empty() || matches!(raw.to_ascii_lowercase().as_str(), "off" | "none" | "disabled") { + return None; + } + if raw.eq_ignore_ascii_case("auto") { + return Some(vec![0]); + } + let mut ports = Vec::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + let range = match item.split_once('-') { + Some((lo, hi)) => (lo.trim().parse::().ok()?, hi.trim().parse::().ok()?), + None => { + let port = item.parse::().ok()?; + (port, port) + } + }; + if range.0 == 0 || range.1 < range.0 { + return None; + } + for port in range.0..=range.1 { + if port != codeg_port && !ports.contains(&port) { + ports.push(port); + } + } + } + if ports.is_empty() { + None + } else { + Some(ports) + } +} + +/// What `browser_bridge_status` tells the workbench. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeStatus { + pub enabled: bool, + /// Ports a listener may take (`0` = any free port). + pub ports: Vec, + pub public_host: Option, +} + +/// A tab's ticket into one listener. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeGrant { + pub target_port: u16, + pub bridge_port: u16, + /// Path on the bridge origin that sets the cookie and redirects to the + /// page (`?to=/path` chooses where). + pub entry_path: String, + pub public_host: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("the port bridge is off on this server")] + Disabled, + #[error("port {0} is codeg's own listener")] + Reserved(u16), + #[error("no bridge port is free: {0}")] + NoPort(String), +} + +struct Listener { + target_port: u16, + bridge_port: u16, + caps: Mutex>, + /// Workbench tabs holding this listener open. + holds: Mutex>, + last_seen: Mutex, + shutdown: Mutex>>, + task: Mutex>>, +} + +impl Listener { + fn has_cap(&self, cap: &str) -> bool { + let caps = lock(&self.caps); + caps.iter().any(|known| constant_time_eq(known.as_bytes(), cap.as_bytes())) + } + + fn touch(&self) { + *lock(&self.last_seen) = Instant::now(); + } + + fn grant(&self, tab_id: &str, public_host: Option) -> BridgeGrant { + let cap = uuid::Uuid::new_v4().simple().to_string(); + lock(&self.caps).push(cap.clone()); + lock(&self.holds).insert(tab_id.to_string()); + self.touch(); + BridgeGrant { + target_port: self.target_port, + bridge_port: self.bridge_port, + entry_path: format!("{ENTER_PREFIX}{cap}"), + public_host, + } + } + + fn cookie_name(&self) -> String { + format!("{COOKIE_PREFIX}{}", self.bridge_port) + } + + /// Stop accepting; connections still open get `CLOSE_GRACE`, then are cut. + fn close(&self) { + if let Some(tx) = lock(&self.shutdown).take() { + let _ = tx.send(()); + } + if let Some(task) = lock(&self.task).take() { + if tokio::runtime::Handle::try_current().is_ok() { + tokio::spawn(async move { + let abort = task.abort_handle(); + if tokio::time::timeout(CLOSE_GRACE, task).await.is_err() { + abort.abort(); + } + }); + } else { + task.abort(); + } + } + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +struct Bridge { + config: Mutex>, + /// Live listeners by target port. + listeners: Mutex>>, +} + +static BRIDGE: LazyLock = LazyLock::new(|| Bridge { + config: Mutex::new(None), + listeners: Mutex::new(HashMap::new()), +}); + +static SWEEPER: OnceLock<()> = OnceLock::new(); + +/// Set (or, with `None`, switch off) the bridge. Switching off closes every +/// listener; changing the configuration keeps the listeners already bound. +pub fn configure(config: Option) { + let off = config.is_none(); + *lock(&BRIDGE.config) = config; + if off { + shutdown_all(); + } +} + +pub fn status() -> BridgeStatus { + match lock(&BRIDGE.config).as_ref() { + Some(config) => BridgeStatus { + enabled: true, + ports: config.ports.clone(), + public_host: config.public_host.clone(), + }, + None => BridgeStatus { + enabled: false, + ports: Vec::new(), + public_host: None, + }, + } +} + +/// Number of live listeners. +pub fn listener_count() -> usize { + lock(&BRIDGE.listeners).len() +} + +/// Let `tab_id` reach `127.0.0.1:{target_port}` through a bridge listener, +/// binding one when the port has none yet. +pub async fn open(target_port: u16, tab_id: &str) -> Result { + let config = lock(&BRIDGE.config).clone().ok_or(BridgeError::Disabled)?; + if config.reserved.contains(&target_port) { + return Err(BridgeError::Reserved(target_port)); + } + if let Some(existing) = lock(&BRIDGE.listeners).get(&target_port).cloned() { + return Ok(existing.grant(tab_id, config.public_host.clone())); + } + + let used: HashSet = lock(&BRIDGE.listeners).values().map(|l| l.bridge_port).collect(); + let mut last_error = String::from("no ports configured"); + for &port in &config.ports { + if port != 0 && used.contains(&port) { + continue; + } + let socket = match bind(&config.bind_host, port).await { + Ok(socket) => socket, + Err(err) => { + last_error = format!("{}:{port}: {err}", config.bind_host); + continue; + } + }; + let bridge_port = socket.local_addr().map(|a| a.port()).unwrap_or(port); + let listener = Arc::new(Listener { + target_port, + bridge_port, + caps: Mutex::new(Vec::new()), + holds: Mutex::new(HashSet::new()), + last_seen: Mutex::new(Instant::now()), + shutdown: Mutex::new(None), + task: Mutex::new(None), + }); + // Another task may have bound this target while we were binding. + let winner = { + let mut listeners = lock(&BRIDGE.listeners); + match listeners.get(&target_port) { + Some(existing) => existing.clone(), + None => { + listeners.insert(target_port, listener.clone()); + listener.clone() + } + } + }; + if Arc::ptr_eq(&winner, &listener) { + serve(socket, listener.clone()); + SWEEPER.get_or_init(|| { + tokio::spawn(sweep_task()); + }); + tracing::info!( + "[bridge] port {bridge_port} now forwards to 127.0.0.1:{target_port}" + ); + } + return Ok(winner.grant(tab_id, config.public_host.clone())); + } + Err(BridgeError::NoPort(last_error)) +} + +/// `tab_id` no longer needs any listener. +pub fn close(tab_id: &str) { + for listener in lock(&BRIDGE.listeners).values() { + lock(&listener.holds).remove(tab_id); + } +} + +/// Close listeners nobody has used for a while; returns how many closed. +pub fn sweep(now: Instant) -> usize { + let stale: Vec> = { + let mut listeners = lock(&BRIDGE.listeners); + let stale: Vec = listeners + .values() + .filter(|l| { + let idle = now.saturating_duration_since(*lock(&l.last_seen)); + let held = !lock(&l.holds).is_empty(); + idle >= if held { HELD_IDLE } else { UNHELD_IDLE } + }) + .map(|l| l.target_port) + .collect(); + stale.iter().filter_map(|port| listeners.remove(port)).collect() + }; + for listener in &stale { + tracing::info!( + "[bridge] port {} closed (127.0.0.1:{} idle)", + listener.bridge_port, + listener.target_port + ); + listener.close(); + } + stale.len() +} + +pub fn shutdown_all() { + let all: Vec> = lock(&BRIDGE.listeners).drain().map(|(_, l)| l).collect(); + for listener in all { + listener.close(); + } +} + +async fn sweep_task() { + loop { + tokio::time::sleep(SWEEP_INTERVAL).await; + sweep(Instant::now()); + } +} + +async fn bind(host: &str, port: u16) -> std::io::Result { + let addr: SocketAddr = format!("{host}:{port}") + .parse() + .or_else(|_| format!("[{host}]:{port}").parse()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + let socket = tokio::net::TcpListener::bind(addr).await?; + if let Err(err) = super::socket_inherit::mark_listener_non_inheritable(&socket) { + tracing::warn!("[bridge] failed to mark listener non-inheritable: {err}"); + } + Ok(socket) +} + +fn serve(socket: tokio::net::TcpListener, listener: Arc) { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let router = Router::new() + .route(PING_PATH, get(ping)) + .route("/__codeg_bridge/enter/{cap}", get(enter)) + .fallback(any(forward)) + .with_state(listener.clone()); + let task = tokio::spawn(async move { + let serve = axum::serve(socket, router).with_graceful_shutdown(async move { + let _ = rx.await; + }); + if let Err(err) = serve.await { + tracing::error!("[bridge] listener error: {err}"); + } + }); + *lock(&listener.shutdown) = Some(tx); + *lock(&listener.task) = Some(task); +} + +// ─── Listener routes ─────────────────────────────────────────────────── + +async fn ping() -> Response { + ( + StatusCode::NO_CONTENT, + [ + (header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"), + (header::CACHE_CONTROL, "no-store"), + ], + ) + .into_response() +} + +async fn enter( + State(listener): State>, + AxumPath(cap): AxumPath, + RawQuery(query): RawQuery, + headers: HeaderMap, +) -> Response { + if !listener.has_cap(&cap) { + return forbidden_page(); + } + listener.touch(); + let to = query + .as_deref() + .and_then(|q| query_param(q, "to")) + .filter(|to| is_local_path(to)) + .unwrap_or_else(|| "/".to_string()); + let secure = if forwarded_https(&headers) { "; Secure" } else { "" }; + let cookie = format!( + "{}={cap}; Path=/; HttpOnly; SameSite=Lax{secure}", + listener.cookie_name() + ); + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, to) + .header(header::SET_COOKIE, cookie) + .header(header::CACHE_CONTROL, "no-store") + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +async fn forward(State(listener): State>, request: Request) -> Response { + let (mut parts, body) = request.into_parts(); + if parts.uri.path().starts_with("/__codeg_bridge/") { + return StatusCode::NOT_FOUND.into_response(); + } + let cookie_name = listener.cookie_name(); + let presented = cookie_value(&parts.headers, &cookie_name); + if !presented.is_some_and(|cap| listener.has_cap(&cap)) { + return forbidden_page(); + } + listener.touch(); + if is_websocket_upgrade(&parts.headers) { + return proxy_websocket(&listener, &mut parts).await; + } + proxy_http(&listener, parts, body).await +} + +// ─── HTTP forwarding ─────────────────────────────────────────────────── + +/// Never follows redirects (the browser must see them), never decodes bodies +/// (they pass through byte for byte, `Content-Length` intact), never goes +/// through a proxy, and has no overall timeout: a dev server's SSE / long +/// poll stays open as long as the page wants. +static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .no_proxy() + .no_gzip() + .no_brotli() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build the bridge client") +}); + +/// Request headers that describe this connection, not the request, plus the +/// ones the bridge sets itself. +fn drop_request_header(name: &str) -> bool { + matches!( + name, + "host" + | "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "expect" + | "content-length" + | "cookie" + | "origin" + | "referer" + ) +} + +/// Response headers that must not reach the browser: connection-level ones, +/// and `X-Frame-Options` — the page is being shown in the user's own workbench +/// on purpose. +fn drop_response_header(name: &str) -> bool { + matches!( + name, + "connection" | "keep-alive" | "transfer-encoding" | "trailer" | "upgrade" | "x-frame-options" + ) +} + +async fn proxy_http(listener: &Listener, parts: Parts, body: Body) -> Response { + let target = listener.target_port; + let path_and_query = parts + .uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + let upstream = format!("http://127.0.0.1:{target}{path_and_query}"); + let mut builder = CLIENT.request(parts.method.clone(), &upstream); + for (name, value) in parts.headers.iter() { + if !drop_request_header(name.as_str()) { + builder = builder.header(name, value); + } + } + for (name, value) in rewritten_request_headers(&parts.headers, target) { + builder = builder.header(name, value); + } + if has_body(&parts.headers) { + builder = builder.body(reqwest::Body::wrap_stream(body.into_data_stream())); + } + + let response = match builder.send().await { + Ok(response) => response, + Err(err) => return bad_gateway_page(target, &err), + }; + + let mut out = Response::builder().status(response.status().as_u16()); + for (name, value) in response.headers().iter() { + if drop_response_header(name.as_str()) { + continue; + } + if name == header::LOCATION { + if let Some(rewritten) = rewrite_location(value, target) { + out = out.header(name, rewritten); + continue; + } + } + out = out.header(name, value); + } + out.body(Body::from_stream(response.bytes_stream())) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +/// `Cookie` without the bridge's own cookies, and `Origin` / `Referer` +/// pointing at the target as the page would if it ran there directly — dev +/// servers compare them with `Host` before they answer a websocket or a +/// module request. +fn rewritten_request_headers(headers: &HeaderMap, target: u16) -> Vec<(HeaderName, HeaderValue)> { + let mut out = Vec::new(); + let cookies = foreign_cookies(headers); + if !cookies.is_empty() { + if let Ok(value) = HeaderValue::from_str(&cookies) { + out.push((header::COOKIE, value)); + } + } + if headers.contains_key(header::ORIGIN) { + if let Ok(value) = HeaderValue::from_str(&format!("http://127.0.0.1:{target}")) { + out.push((header::ORIGIN, value)); + } + } + if let Some(referer) = headers.get(header::REFERER).and_then(|v| v.to_str().ok()) { + if let Ok(url) = reqwest::Url::parse(referer) { + let mut rewritten = format!("http://127.0.0.1:{target}{}", url.path()); + if let Some(query) = url.query() { + rewritten.push('?'); + rewritten.push_str(query); + } + if let Ok(value) = HeaderValue::from_str(&rewritten) { + out.push((header::REFERER, value)); + } + } + } + out +} + +fn has_body(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > 0) + || headers.contains_key(header::TRANSFER_ENCODING) +} + +/// An absolute `Location` on the target itself becomes a path on the bridge +/// origin; anything else (another host, a relative path) passes unchanged. +fn rewrite_location(value: &HeaderValue, target: u16) -> Option { + let raw = value.to_str().ok()?; + let url = reqwest::Url::parse(raw).ok()?; + if !matches!(url.scheme(), "http" | "https") { + return None; + } + let host = url.host_str()?; + if !is_loopback_host(host) || url.port_or_known_default() != Some(target) { + return None; + } + let mut path = url.path().to_string(); + if let Some(query) = url.query() { + path.push('?'); + path.push_str(query); + } + if let Some(fragment) = url.fragment() { + path.push('#'); + path.push_str(fragment); + } + HeaderValue::from_str(&path).ok() +} + +// ─── WebSocket forwarding ────────────────────────────────────────────── + +fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + let upgrade = headers + .get(header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")); + let connection = headers + .get(header::CONNECTION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.split(',').any(|part| part.trim().eq_ignore_ascii_case("upgrade"))); + upgrade && connection +} + +async fn proxy_websocket(listener: &Listener, parts: &mut Parts) -> Response { + let target = listener.target_port; + let upgrade = match WebSocketUpgrade::from_request_parts(parts, &()).await { + Ok(upgrade) => upgrade, + Err(rejection) => return rejection.into_response(), + }; + let path_and_query = parts + .uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + let mut request = match format!("ws://127.0.0.1:{target}{path_and_query}").into_client_request() + { + Ok(request) => request, + Err(err) => return bad_gateway_page(target, &err), + }; + for name in [header::SEC_WEBSOCKET_PROTOCOL, header::USER_AGENT, header::ACCEPT_LANGUAGE] { + if let Some(value) = parts.headers.get(&name) { + request.headers_mut().insert(name, value.clone()); + } + } + for (name, value) in rewritten_request_headers(&parts.headers, target) { + request.headers_mut().insert(name, value); + } + + let (upstream, response) = match tokio_tungstenite::connect_async(request).await { + Ok(connected) => connected, + Err(err) => return bad_gateway_page(target, &err), + }; + let mut upgrade = upgrade; + if let Some(protocol) = response + .headers() + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + { + upgrade = upgrade.protocols([protocol.to_string()]); + } + upgrade.on_upgrade(move |socket| pump(socket, upstream)) +} + +async fn pump( + downstream: WebSocket, + upstream: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + let (mut down_tx, mut down_rx) = downstream.split(); + let (mut up_tx, mut up_rx) = upstream.split(); + let to_upstream = async { + while let Some(Ok(message)) = down_rx.next().await { + let close = matches!(message, DownMessage::Close(_)); + if up_tx.send(downstream_to_upstream(message)).await.is_err() || close { + break; + } + } + let _ = up_tx.close().await; + }; + let to_downstream = async { + while let Some(Ok(message)) = up_rx.next().await { + let Some(message) = upstream_to_downstream(message) else { + continue; + }; + let close = matches!(message, DownMessage::Close(_)); + if down_tx.send(message).await.is_err() || close { + break; + } + } + let _ = down_tx.close().await; + }; + tokio::select! { + _ = to_upstream => {} + _ = to_downstream => {} + } +} + +fn downstream_to_upstream(message: DownMessage) -> UpMessage { + match message { + DownMessage::Text(text) => UpMessage::Text(text.as_str().into()), + DownMessage::Binary(bytes) => UpMessage::Binary(bytes), + DownMessage::Ping(bytes) => UpMessage::Ping(bytes), + DownMessage::Pong(bytes) => UpMessage::Pong(bytes), + DownMessage::Close(frame) => UpMessage::Close(frame.map(|f| UpCloseFrame { + code: f.code.into(), + reason: f.reason.as_str().into(), + })), + } +} + +fn upstream_to_downstream(message: UpMessage) -> Option { + Some(match message { + UpMessage::Text(text) => DownMessage::Text(text.as_str().into()), + UpMessage::Binary(bytes) => DownMessage::Binary(bytes), + UpMessage::Ping(bytes) => DownMessage::Ping(bytes), + UpMessage::Pong(bytes) => DownMessage::Pong(bytes), + UpMessage::Close(frame) => DownMessage::Close(frame.map(|f| CloseFrame { + code: u16::from(f.code), + reason: f.reason.as_str().into(), + })), + UpMessage::Frame(_) => return None, + }) +} + +// ─── Small helpers ───────────────────────────────────────────────────── + +fn query_param(raw: &str, key: &str) -> Option { + raw.split('&').find_map(|segment| { + let (name, value) = segment.split_once('=').unwrap_or((segment, "")); + if name != key { + return None; + } + Some( + urlencoding::decode(value) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| value.to_string()), + ) + }) +} + +/// A path the entry may redirect to: root-relative on this origin only, so +/// the entry URL cannot be used to send the browser somewhere else. +fn is_local_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.starts_with("/\\") + && !path.chars().any(|c| c.is_control() || c.is_whitespace()) +} + +fn forwarded_https(headers: &HeaderMap) -> bool { + headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.split(',').next().is_some_and(|p| p.trim().eq_ignore_ascii_case("https"))) +} + +fn cookie_pairs(headers: &HeaderMap) -> Vec<(String, String)> { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|line| line.split(';')) + .filter_map(|pair| { + let (name, value) = pair.trim().split_once('=')?; + Some((name.trim().to_string(), value.trim().to_string())) + }) + .collect() +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + cookie_pairs(headers) + .into_iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v) +} + +/// The page's own cookies, re-serialized without the bridge's and without +/// the workbench's (both share the host with the page). +fn foreign_cookies(headers: &HeaderMap) -> String { + cookie_pairs(headers) + .into_iter() + .filter(|(name, _)| { + !name.starts_with(COOKIE_PREFIX) && !name.starts_with(WORKBENCH_COOKIE_PREFIX) + }) + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; ") +} + +/// `localhost`, `*.localhost`, `127/8`, `::1` and the unspecified addresses, +/// which a browser connects to as local. +pub fn is_loopback_host(host: &str) -> bool { + let host = host.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + if host == "::1" || host == "::" || host == "0.0.0.0" { + return true; + } + let host = host.strip_prefix("::ffff:").unwrap_or(&host); + match host.parse::() { + Ok(ip) => ip.octets()[0] == 127, + Err(_) => false, + } +} + +fn html_page(status: StatusCode, title: &str, body: &str) -> Response { + let html = format!( + "{title}\ + \ +

{title}

{body}

" + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from(html)) + .unwrap_or_else(|_| status.into_response()) +} + +fn forbidden_page() -> Response { + html_page( + StatusCode::FORBIDDEN, + "This preview is no longer valid", + "Reopen the page from codeg to start a new preview session.", + ) +} + +fn bad_gateway_page(target: u16, err: &dyn std::fmt::Display) -> Response { + let detail = escape_html(&err.to_string()); + html_page( + StatusCode::BAD_GATEWAY, + &format!("codeg cannot reach port {target} on its host"), + &format!("Is the server still running there? Reload to try again. ({detail})"), + ) +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_pools_parse_ranges_lists_and_switches() { + assert_eq!(parse_ports("3081-3083", 3080), Some(vec![3081, 3082, 3083])); + assert_eq!(parse_ports("3081, 3090,3081", 3080), Some(vec![3081, 3090])); + assert_eq!(parse_ports("3079-3082", 3080), Some(vec![3079, 3081, 3082])); + assert_eq!(parse_ports("auto", 3080), Some(vec![0])); + assert_eq!(parse_ports("AUTO", 3080), Some(vec![0])); + for off in ["", " ", "off", "none", "Disabled"] { + assert_eq!(parse_ports(off, 3080), None, "{off:?}"); + } + // A typo must not turn into a default pool. + assert_eq!(parse_ports("3081-", 3080), None); + assert_eq!(parse_ports("abc", 3080), None); + assert_eq!(parse_ports("3090-3081", 3080), None); + assert_eq!(parse_ports("0-3", 3080), None); + // Only codeg's own port: nothing left. + assert_eq!(parse_ports("3080", 3080), None); + } + + #[test] + fn default_pool_is_the_ten_ports_above_codeg() { + assert_eq!(default_ports(3080), (3081..=3090).collect::>()); + assert_eq!(default_ports(65533), vec![65534, 65535]); + } + + #[test] + fn entry_redirects_stay_on_this_origin() { + assert!(is_local_path("/")); + assert!(is_local_path("/docs?x=1#top")); + assert!(!is_local_path("")); + assert!(!is_local_path("//evil.example/")); + assert!(!is_local_path("/\\evil.example/")); + assert!(!is_local_path("http://evil.example/")); + assert!(!is_local_path("/a\r\nSet-Cookie: x=y")); + assert!(!is_local_path("/with space")); + } + + #[test] + fn cookies_are_read_and_filtered() { + let mut headers = HeaderMap::new(); + headers.append( + header::COOKIE, + HeaderValue::from_static("a=1; codeg-bridge-3081=cap-one; b=2"), + ); + headers.append( + header::COOKIE, + HeaderValue::from_static("codeg-bridge-3082=cap-two; codeg.locale=zh-CN"), + ); + assert_eq!(cookie_value(&headers, "codeg-bridge-3081").as_deref(), Some("cap-one")); + assert_eq!(cookie_value(&headers, "codeg-bridge-3082").as_deref(), Some("cap-two")); + assert_eq!(cookie_value(&headers, "codeg-bridge-3083"), None); + assert_eq!(foreign_cookies(&headers), "a=1; b=2"); + assert_eq!(foreign_cookies(&HeaderMap::new()), ""); + } + + #[test] + fn origin_and_referer_point_at_the_target() { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static("http://codeg.example:3081")); + headers.insert( + header::REFERER, + HeaderValue::from_static("http://codeg.example:3081/app/page?tab=2"), + ); + headers.insert(header::COOKIE, HeaderValue::from_static("codeg-bridge-3081=c; sid=9")); + let rewritten = rewritten_request_headers(&headers, 3000); + let get = |name: HeaderName| { + rewritten + .iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| v.to_str().unwrap().to_string()) + }; + assert_eq!(get(header::ORIGIN).as_deref(), Some("http://127.0.0.1:3000")); + assert_eq!( + get(header::REFERER).as_deref(), + Some("http://127.0.0.1:3000/app/page?tab=2") + ); + assert_eq!(get(header::COOKIE).as_deref(), Some("sid=9")); + + // `Origin: null` (a sandboxed frame) is rewritten too; no Origin at + // all stays absent. + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static("null")); + let rewritten = rewritten_request_headers(&headers, 3000); + assert_eq!(rewritten.len(), 1); + assert!(rewritten_request_headers(&HeaderMap::new(), 3000).is_empty()); + } + + #[test] + fn location_on_the_target_becomes_a_path() { + let rewrite = |raw: &str| { + rewrite_location(&HeaderValue::from_str(raw).unwrap(), 3000) + .map(|v| v.to_str().unwrap().to_string()) + }; + assert_eq!(rewrite("http://127.0.0.1:3000/login?next=%2F").as_deref(), Some("/login?next=%2F")); + assert_eq!(rewrite("http://localhost:3000/a#b").as_deref(), Some("/a#b")); + assert_eq!(rewrite("http://[::1]:3000/").as_deref(), Some("/")); + assert_eq!(rewrite("http://0.0.0.0:3000/x").as_deref(), Some("/x")); + // Another port, another host, a relative path: untouched. + assert_eq!(rewrite("http://127.0.0.1:3001/"), None); + assert_eq!(rewrite("https://example.com/"), None); + assert_eq!(rewrite("/relative"), None); + assert_eq!(rewrite("login"), None); + } + + #[test] + fn loopback_hosts() { + for host in ["localhost", "LOCALHOST", "app.localhost", "127.0.0.1", "127.1.2.3", "::1", "[::1]", "0.0.0.0", "::", "::ffff:127.0.0.1"] { + assert!(is_loopback_host(host), "{host}"); + } + for host in ["example.com", "10.0.0.1", "192.168.1.5", "128.0.0.1", "localhost.evil", "fe80::1", ""] { + assert!(!is_loopback_host(host), "{host}"); + } + } + + #[test] + fn header_filters() { + for name in ["host", "connection", "cookie", "origin", "referer", "content-length", "upgrade", "expect"] { + assert!(drop_request_header(name), "{name}"); + } + for name in ["accept", "authorization", "content-type", "accept-encoding", "sec-fetch-site", "x-requested-with"] { + assert!(!drop_request_header(name), "{name}"); + } + for name in ["x-frame-options", "connection", "transfer-encoding"] { + assert!(drop_response_header(name), "{name}"); + } + for name in ["content-type", "content-length", "content-encoding", "set-cookie", "location", "content-security-policy", "cache-control"] { + assert!(!drop_response_header(name), "{name}"); + } + } + + #[test] + fn websocket_upgrade_detection() { + let mut headers = HeaderMap::new(); + assert!(!is_websocket_upgrade(&headers)); + headers.insert(header::UPGRADE, HeaderValue::from_static("WebSocket")); + assert!(!is_websocket_upgrade(&headers)); + headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive, Upgrade")); + assert!(is_websocket_upgrade(&headers)); + } + + #[test] + fn status_and_wire_names() { + let json = serde_json::to_value(BridgeGrant { + target_port: 3000, + bridge_port: 3081, + entry_path: "/__codeg_bridge/enter/abc".into(), + public_host: None, + }) + .unwrap(); + assert_eq!(json["bridgePort"], 3081); + assert_eq!(json["entryPath"], "/__codeg_bridge/enter/abc"); + assert!(json["publicHost"].is_null()); + } + + #[test] + fn forwarded_proto_marks_secure_cookies() { + let mut headers = HeaderMap::new(); + assert!(!forwarded_https(&headers)); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http")); + assert!(forwarded_https(&headers)); + headers.insert("x-forwarded-proto", HeaderValue::from_static("http")); + assert!(!forwarded_https(&headers)); + } +} diff --git a/src-tauri/src/web/handlers/browser_bridge.rs b/src-tauri/src/web/handlers/browser_bridge.rs new file mode 100644 index 0000000000..ac2e5ae0e1 --- /dev/null +++ b/src-tauri/src/web/handlers/browser_bridge.rs @@ -0,0 +1,120 @@ +//! Token-authenticated API of the web-mode port bridge: the workbench asks +//! here (with codeg's token) for a grant, then loads the bridge listener's +//! entry URL in an iframe. See `web::browser_bridge` for the listener side. + +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::app_error::{AppCommandError, AppErrorCode}; +use crate::web::browser_bridge::{self, BridgeError, BridgeGrant, BridgeStatus}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeOpenParams { + /// The address as the user saw it (`http://localhost:3000/docs?x=1`). + pub url: String, + /// The workbench tab that will hold the grant; `browser_bridge_close` + /// releases it by the same id. + pub tab_id: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeOpenResult { + #[serde(flatten)] + pub grant: BridgeGrant, + /// Path and query of `url`, for the entry redirect (`?to=`). + pub path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCloseParams { + pub tab_id: String, +} + +pub async fn browser_bridge_status() -> Json { + Json(browser_bridge::status()) +} + +pub async fn browser_bridge_open( + Json(params): Json, +) -> Result, AppCommandError> { + let (port, path) = bridgeable_target(¶ms.url)?; + let grant = browser_bridge::open(port, ¶ms.tab_id) + .await + .map_err(|err| match err { + BridgeError::Disabled => AppCommandError::configuration_missing(err.to_string()), + BridgeError::Reserved(_) => AppCommandError::invalid_input(err.to_string()), + BridgeError::NoPort(_) => { + AppCommandError::new(AppErrorCode::IoError, "no bridge port is free") + .with_detail(err.to_string()) + } + })?; + Ok(Json(BridgeOpenResult { grant, path })) +} + +pub async fn browser_bridge_close( + Json(params): Json, +) -> Json { + browser_bridge::close(¶ms.tab_id); + Json(serde_json::json!({ "ok": true })) +} + +/// The loopback port `url` names and the path to land on. Only plain `http` +/// to the host's own loopback can be bridged: the bridge speaks to the target +/// without TLS, and a private-network or public host would make codeg a +/// general-purpose proxy. +pub fn bridgeable_target(url: &str) -> Result<(u16, String), AppCommandError> { + let parsed = reqwest::Url::parse(url.trim()) + .map_err(|e| AppCommandError::invalid_input(format!("not a URL: {e}")))?; + if parsed.scheme() != "http" { + return Err(AppCommandError::invalid_input( + "only http:// addresses on the server's loopback can be bridged", + )); + } + let host = parsed.host_str().unwrap_or_default(); + if !browser_bridge::is_loopback_host(host) { + return Err(AppCommandError::invalid_input(format!( + "{host} is not the server's loopback; only localhost ports can be bridged" + ))); + } + let port = parsed.port_or_known_default().unwrap_or(80); + let mut path = parsed.path().to_string(); + if let Some(query) = parsed.query() { + path.push('?'); + path.push_str(query); + } + Ok((port, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_http_is_bridgeable_and_keeps_its_path() { + assert_eq!( + bridgeable_target("http://localhost:3000/docs?x=1#frag").unwrap(), + (3000, "/docs?x=1".to_string()) + ); + assert_eq!(bridgeable_target("http://127.0.0.1:5173").unwrap(), (5173, "/".to_string())); + assert_eq!(bridgeable_target("http://[::1]:8080/a/b").unwrap(), (8080, "/a/b".to_string())); + assert_eq!(bridgeable_target("http://0.0.0.0:3000/").unwrap(), (3000, "/".to_string())); + assert_eq!(bridgeable_target("http://localhost/").unwrap(), (80, "/".to_string())); + } + + #[test] + fn everything_else_is_refused() { + for url in [ + "https://localhost:3000/", + "http://192.168.1.5:3000/", + "http://example.com/", + "ws://localhost:3000/", + "localhost:3000", + "", + ] { + assert!(bridgeable_target(url).is_err(), "{url}"); + } + } +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 6a38e353f5..1657b304db 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod automation; pub mod canvas; pub mod background; pub mod backup; +pub mod browser_bridge; pub mod chat_authoring; pub mod chat_channel; pub mod conversations; diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index ec2682db9d..34a195dfaa 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod browser_bridge; pub mod compression; pub mod event_bridge; pub mod handlers; @@ -601,6 +602,7 @@ pub(crate) async fn do_start_web_server_with_state( // Advertise the IP the socket is actually bound to, not the raw config. let advertised_host = advertise_host(local_addr, &host); tracing::info!("[WEB] Starting web server on {}", addr); + configure_browser_bridge(&host, actual_port); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { @@ -628,7 +630,35 @@ pub(crate) async fn do_start_web_server_with_state( }) } +/// Bridge listeners follow the web service: same bind address, the ports +/// after its own unless `CODEG_BRIDGE_PORTS` says otherwise. +fn configure_browser_bridge(bind_host: &str, port: u16) { + let config = browser_bridge::BridgeConfig::from_env(bind_host, port); + match &config { + Some(config) => tracing::info!( + "[WEB] Port bridge for dev servers: ports {}", + describe_ports(&config.ports) + ), + None => tracing::info!("[WEB] Port bridge for dev servers: off (CODEG_BRIDGE_PORTS)"), + } + browser_bridge::configure(config); +} + +/// `3081-3090` for a contiguous pool, the list otherwise, `any free port` for `0`. +pub fn describe_ports(ports: &[u16]) -> String { + if ports == [0] { + return "any free port".to_string(); + } + let contiguous = ports.windows(2).all(|w| w[1] == w[0] + 1); + match (ports.first(), ports.last()) { + (Some(first), Some(last)) if contiguous && ports.len() > 2 => format!("{first}-{last}"), + _ => ports.iter().map(|p| p.to_string()).collect::>().join(", "), + } +} + pub(crate) async fn do_stop_web_server(state: &WebServerState) { + // The bridge listeners belong to this web service: no API, no grants. + browser_bridge::configure(None); let handle_opt = state.handle.lock().unwrap().take(); let shutdown_tx = state.shutdown_tx.lock().unwrap().take(); @@ -876,6 +906,7 @@ pub(crate) async fn do_start_web_server_tauri( // Advertise the IP the socket is actually bound to, not the raw config. let advertised_host = advertise_host(local_addr, &host_val); tracing::info!("[WEB] Starting web server on {}", addr); + configure_browser_bridge(&host_val, actual_port); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index ed6baffcbb..894b8a4f04 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1088,6 +1088,19 @@ pub fn build_router( post(handlers::custom_skills::custom_delete_skills), ) // ─── Office tools ─── + // ─── Web-mode port bridge (dev servers on the host, shown in an iframe) ─── + .route( + "/browser_bridge_status", + post(handlers::browser_bridge::browser_bridge_status), + ) + .route( + "/browser_bridge_open", + post(handlers::browser_bridge::browser_bridge_open), + ) + .route( + "/browser_bridge_close", + post(handlers::browser_bridge::browser_bridge_close), + ) .route( "/officecli_detect", post(handlers::office_tools::officecli_detect), diff --git a/src-tauri/tests/browser_bridge.rs b/src-tauri/tests/browser_bridge.rs new file mode 100644 index 0000000000..f89cb9076d --- /dev/null +++ b/src-tauri/tests/browser_bridge.rs @@ -0,0 +1,440 @@ +//! Integration tests for the web-mode port bridge (`web::browser_bridge`): +//! real listeners on loopback, a real upstream standing in for a dev server, +//! and a real WebSocket through both. + +use std::sync::OnceLock; + +use axum::extract::ws::{Message, WebSocketUpgrade}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use codeg_lib::web::browser_bridge::{self, BridgeConfig, BridgeError, BridgeGrant}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; + +const RESERVED_PORT: u16 = 1; + +fn configure_once() { + static ONCE: OnceLock<()> = OnceLock::new(); + ONCE.get_or_init(|| { + browser_bridge::configure(Some(BridgeConfig { + bind_host: "127.0.0.1".to_string(), + ports: vec![0], + public_host: None, + reserved: vec![RESERVED_PORT], + })); + }); +} + +/// A loopback server standing in for a dev server. Echoes the request +/// headers it cares about back as `x-echo-*` response headers. +async fn spawn_upstream() -> u16 { + async fn hello(headers: HeaderMap) -> Response { + let echo = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + Response::builder() + .status(StatusCode::OK) + .header("x-frame-options", "DENY") + .header("set-cookie", "sid=1; Path=/") + .header("x-echo-cookie", echo("cookie")) + .header("x-echo-origin", echo("origin")) + .header("x-echo-referer", echo("referer")) + .header("x-echo-host", echo("host")) + .header("x-echo-accept-encoding", echo("accept-encoding")) + .header(header::CONTENT_TYPE, "text/plain") + .body(axum::body::Body::from("hello from upstream")) + .unwrap() + } + async fn redirect(headers: HeaderMap) -> Response { + let host = headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("127.0.0.1"); + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, format!("http://{host}/after?x=1")) + .body(axum::body::Body::empty()) + .unwrap() + } + async fn echo(headers: HeaderMap, body: String) -> Response { + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + headers + .get(header::CONTENT_TYPE) + .cloned() + .unwrap_or_else(|| "text/plain".parse().unwrap()), + ) + .body(axum::body::Body::from(format!("echo:{body}"))) + .unwrap() + } + async fn ws(ws: WebSocketUpgrade, headers: HeaderMap) -> Response { + let origin = headers + .get("origin") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + ws.protocols(["vite-hmr"]).on_upgrade(move |mut socket| async move { + let _ = socket + .send(Message::Text(format!("origin:{origin}").into())) + .await; + while let Some(Ok(message)) = socket.recv().await { + match message { + Message::Text(text) => { + if socket + .send(Message::Text(format!("echo:{text}").into())) + .await + .is_err() + { + break; + } + } + Message::Close(_) => break, + _ => {} + } + } + }) + } + let app = Router::new() + .route("/hello", get(hello)) + .route("/redirect", get(redirect)) + .route("/echo", post(echo)) + .route("/ws", get(ws)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + port +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .unwrap() +} + +fn cap_of(grant: &BridgeGrant) -> &str { + grant + .entry_path + .strip_prefix(browser_bridge::ENTER_PREFIX) + .expect("entry path carries the capability") +} + +fn cookie_for(grant: &BridgeGrant) -> String { + format!("codeg-bridge-{}={}", grant.bridge_port, cap_of(grant)) +} + +fn base(grant: &BridgeGrant) -> String { + format!("http://127.0.0.1:{}", grant.bridge_port) +} + +#[tokio::test] +async fn entry_sets_the_cookie_and_redirects_to_the_page() { + configure_once(); + let upstream = spawn_upstream().await; + let grant = browser_bridge::open(upstream, "tab-entry").await.unwrap(); + assert_eq!(grant.target_port, upstream); + assert_ne!(grant.bridge_port, 0); + + let response = client() + .get(format!( + "{}{}?to=%2Fhello%3Fq%3D1", + base(&grant), + grant.entry_path + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/hello?q=1" + ); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!( + cookie, + format!("{}; Path=/; HttpOnly; SameSite=Lax", cookie_for(&grant)) + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + + // Behind a TLS-terminating proxy the cookie is marked Secure. + let response = client() + .get(format!("{}{}", base(&grant), grant.entry_path)) + .header("x-forwarded-proto", "https") + .send() + .await + .unwrap(); + assert!(response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .ends_with("; Secure")); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/"); + + // The redirect target must stay on this origin. + let response = client() + .get(format!( + "{}{}?to=%2F%2Fevil.example%2F", + base(&grant), + grant.entry_path + )) + .send() + .await + .unwrap(); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/"); + + // A capability the listener never issued sets nothing. + let response = client() + .get(format!( + "{}{}nope", + base(&grant), + browser_bridge::ENTER_PREFIX + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response.headers().get(header::SET_COOKIE).is_none()); +} + +#[tokio::test] +async fn requests_need_this_listeners_cookie() { + configure_once(); + let upstream = spawn_upstream().await; + let grant = browser_bridge::open(upstream, "tab-cookie").await.unwrap(); + let url = format!("{}/hello", base(&grant)); + + // No cookie, a wrong value, another listener's name: all refused before + // the upstream is touched. + let response = client().get(&url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client() + .get(&url) + .header(header::COOKIE, format!("codeg-bridge-{}=wrong", grant.bridge_port)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client() + .get(&url) + .header( + header::COOKIE, + format!("codeg-bridge-{}={}", grant.bridge_port + 1, cap_of(&grant)), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let forbidden = response.text().await.unwrap(); + assert!(forbidden.contains("Reopen the page from codeg")); + + // With the cookie the page comes through, without the anti-framing + // header and with its own cookies; the request the upstream saw looked + // like a direct one. + let response = client() + .get(&url) + .header( + header::COOKIE, + format!("{}; codeg.locale=zh-CN; sid=abc", cookie_for(&grant)), + ) + .header(header::ORIGIN, "http://codeg.example:3080") + .header(header::REFERER, format!("{}/from/here?tab=2", base(&grant))) + .header(header::ACCEPT_ENCODING, "gzip, br") + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let headers = response.headers().clone(); + assert!(headers.get("x-frame-options").is_none()); + assert_eq!(headers.get(header::SET_COOKIE).unwrap(), "sid=1; Path=/"); + assert_eq!(headers.get("x-echo-cookie").unwrap(), "sid=abc"); + assert_eq!( + headers.get("x-echo-origin").unwrap(), + format!("http://127.0.0.1:{upstream}").as_str() + ); + assert_eq!( + headers.get("x-echo-referer").unwrap(), + format!("http://127.0.0.1:{upstream}/from/here?tab=2").as_str() + ); + assert_eq!( + headers.get("x-echo-host").unwrap(), + format!("127.0.0.1:{upstream}").as_str() + ); + assert_eq!(headers.get("x-echo-accept-encoding").unwrap(), "gzip, br"); + assert_eq!(response.text().await.unwrap(), "hello from upstream"); +} + +#[tokio::test] +async fn redirects_and_bodies_pass_through() { + configure_once(); + let upstream = spawn_upstream().await; + let grant = browser_bridge::open(upstream, "tab-redirect").await.unwrap(); + + let response = client() + .get(format!("{}/redirect", base(&grant))) + .header(header::COOKIE, cookie_for(&grant)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FOUND); + // The upstream answered with its own absolute address; the browser must + // stay on the bridge origin. + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/after?x=1"); + + let response = client() + .post(format!("{}/echo", base(&grant))) + .header(header::COOKIE, cookie_for(&grant)) + .header(header::CONTENT_TYPE, "application/json") + .body("{\"a\":1}") + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!(response.text().await.unwrap(), "echo:{\"a\":1}"); + + // A path outside the page's space on the bridge itself. + let response = client() + .get(format!("{}/__codeg_bridge/other", base(&grant))) + .header(header::COOKIE, cookie_for(&grant)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // The reachability probe needs nothing. + let response = client() + .get(format!("{}{}", base(&grant), browser_bridge::PING_PATH)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap(), + "*" + ); +} + +#[tokio::test] +async fn websockets_are_bridged_with_their_subprotocol() { + configure_once(); + let upstream = spawn_upstream().await; + let grant = browser_bridge::open(upstream, "tab-ws").await.unwrap(); + + let mut request = format!("ws://127.0.0.1:{}/ws", grant.bridge_port) + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert(header::COOKIE, cookie_for(&grant).parse().unwrap()); + request + .headers_mut() + .insert(header::SEC_WEBSOCKET_PROTOCOL, "vite-hmr".parse().unwrap()); + request + .headers_mut() + .insert(header::ORIGIN, format!("{}", base(&grant)).parse().unwrap()); + let (mut socket, response) = tokio_tungstenite::connect_async(request).await.unwrap(); + assert_eq!( + response.headers().get(header::SEC_WEBSOCKET_PROTOCOL).unwrap(), + "vite-hmr" + ); + // The upstream saw an Origin naming itself, as a page served directly + // by it would send. + let first = socket.next().await.unwrap().unwrap(); + assert_eq!( + first.into_text().unwrap().as_str(), + format!("origin:http://127.0.0.1:{upstream}") + ); + socket + .send(tokio_tungstenite::tungstenite::Message::Text("ping".into())) + .await + .unwrap(); + let reply = socket.next().await.unwrap().unwrap(); + assert_eq!(reply.into_text().unwrap().as_str(), "echo:ping"); + socket.close(None).await.unwrap(); + + // Without the cookie the upgrade is refused. + let request = format!("ws://127.0.0.1:{}/ws", grant.bridge_port) + .into_client_request() + .unwrap(); + let err = tokio_tungstenite::connect_async(request).await.unwrap_err(); + assert!( + matches!( + err, + tokio_tungstenite::tungstenite::Error::Http(ref response) + if response.status() == StatusCode::FORBIDDEN + ), + "{err:?}" + ); +} + +#[tokio::test] +async fn tabs_share_a_listener_per_target_port() { + configure_once(); + let upstream = spawn_upstream().await; + let other_upstream = spawn_upstream().await; + let first = browser_bridge::open(upstream, "tab-a").await.unwrap(); + let second = browser_bridge::open(upstream, "tab-b").await.unwrap(); + let other = browser_bridge::open(other_upstream, "tab-c").await.unwrap(); + + assert_eq!(first.bridge_port, second.bridge_port); + assert_ne!(cap_of(&first), cap_of(&second)); + assert_ne!(other.bridge_port, first.bridge_port); + + // Both capabilities open the shared listener; neither opens the other. + for grant in [&first, &second] { + let response = client() + .get(format!("{}/hello", base(&first))) + .header(header::COOKIE, cookie_for(grant)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + let response = client() + .get(format!("{}/hello", base(&other))) + .header( + header::COOKIE, + format!("codeg-bridge-{}={}", other.bridge_port, cap_of(&first)), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn codegs_own_port_is_refused() { + configure_once(); + let err = browser_bridge::open(RESERVED_PORT, "tab-reserved") + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Reserved(p) if p == RESERVED_PORT)); +} diff --git a/src-tauri/tests/browser_bridge_idle.rs b/src-tauri/tests/browser_bridge_idle.rs new file mode 100644 index 0000000000..7f79967658 --- /dev/null +++ b/src-tauri/tests/browser_bridge_idle.rs @@ -0,0 +1,84 @@ +//! The idle sweep of the web-mode port bridge, in its own process: `sweep` +//! is global, and a sweep run "two hours from now" would close the listeners +//! of every other test sharing the binary. + +use std::time::{Duration, Instant}; + +use axum::http::header; +use axum::Router; +use codeg_lib::web::browser_bridge::{self, BridgeConfig, BridgeGrant}; + +fn configure() { + browser_bridge::configure(Some(BridgeConfig { + bind_host: "127.0.0.1".to_string(), + ports: vec![0], + public_host: None, + reserved: vec![1], + })); +} + +async fn spawn_upstream() -> u16 { + let app = Router::new().route("/hello", axum::routing::get(|| async { "hello" })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + port +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .unwrap() +} + +fn cookie_for(grant: &BridgeGrant) -> String { + let cap = grant + .entry_path + .strip_prefix(browser_bridge::ENTER_PREFIX) + .unwrap(); + format!("codeg-bridge-{}={cap}", grant.bridge_port) +} + +fn base(grant: &BridgeGrant) -> String { + format!("http://127.0.0.1:{}", grant.bridge_port) +} + +#[tokio::test] +async fn listeners_close_when_released_and_idle() { + configure(); + let upstream = spawn_upstream().await; + let grant = browser_bridge::open(upstream, "tab-idle").await.unwrap(); + let before = browser_bridge::listener_count(); + let now = Instant::now(); + + // Held and recently used: a minute of idleness is not enough. + assert_eq!(browser_bridge::sweep(now + Duration::from_secs(61)), 0); + // Released: a minute is. + browser_bridge::close("tab-idle"); + assert_eq!(browser_bridge::sweep(now + Duration::from_secs(30)), 0); + assert_eq!(browser_bridge::sweep(now + Duration::from_secs(61)), 1); + assert_eq!(browser_bridge::listener_count(), before - 1); + + // The port stops answering (graceful close, then cut). + tokio::time::sleep(Duration::from_millis(200)).await; + let result = client() + .get(format!("{}/hello", base(&grant))) + .header(header::COOKIE, cookie_for(&grant)) + .send() + .await; + assert!(result.is_err(), "closed listener still answered: {result:?}"); + + // A held listener still closes after two hours without a request. + let held = browser_bridge::open(upstream, "tab-held").await.unwrap(); + assert_ne!(held.bridge_port, 0); + assert_eq!(browser_bridge::sweep(now + Duration::from_secs(60 * 60)), 0); + assert_eq!( + browser_bridge::sweep(now + Duration::from_secs(2 * 60 * 60 + 1)), + 1 + ); + browser_bridge::close("tab-held"); +} diff --git a/src/components/browser/browser-bridge-view.test.tsx b/src/components/browser/browser-bridge-view.test.tsx new file mode 100644 index 0000000000..07af12a787 --- /dev/null +++ b/src/components/browser/browser-bridge-view.test.tsx @@ -0,0 +1,199 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import type { BridgeGrant } from "@/lib/browser/browser-bridge" + +const api = vi.hoisted(() => ({ + bridgeOpen: vi.fn(), + bridgeClose: vi.fn(() => Promise.resolve()), + probeBridge: vi.fn(() => Promise.resolve(true)), + openExternalTab: vi.fn(), + copyTextToClipboard: vi.fn(() => Promise.resolve(true)), + isDesktop: vi.fn(() => false), +})) + +vi.mock("@/lib/browser/browser-bridge", async (importOriginal) => ({ + ...(await importOriginal()), + bridgeOpen: api.bridgeOpen, + bridgeClose: api.bridgeClose, + probeBridge: api.probeBridge, +})) +vi.mock("@/lib/link-open", () => ({ + openExternalTab: api.openExternalTab, +})) +vi.mock("@/lib/utils", async (importOriginal) => ({ + ...(await importOriginal()), + copyTextToClipboard: api.copyTextToClipboard, +})) +vi.mock("@/lib/transport", async (importOriginal) => ({ + ...(await importOriginal()), + isDesktop: () => api.isDesktop(), +})) +// The native view pulls in the surface host and the tab store; the web +// branch must not need any of it. +vi.mock("./browser-surface-host", () => ({ + BrowserSurfaceHost: () =>
, +})) + +import enMessages from "@/i18n/messages/en.json" +import { bridgeEntryUrl } from "@/lib/browser/browser-bridge" +import { buildFileTabId } from "@/lib/file-tab-id" + +import { BRIDGE_FRAME_SANDBOX, BrowserBridgeView } from "./browser-bridge-view" +import { BrowserTabView } from "./browser-tab-view" + +const grant: BridgeGrant = { + targetPort: 3000, + bridgePort: 3081, + entryPath: "/__codeg_bridge/enter/cap-one", + publicHost: null, + path: "/docs?x=1", +} + +function tab(url = "http://localhost:3000/docs?x=1"): BrowserWorkspaceTab { + return { + id: buildFileTabId({ kind: "browser", id: "tab-1" }), + kind: "browser", + folderId: null, + title: "localhost:3000", + description: null, + path: null, + language: "browser", + content: "", + loading: true, + readonly: true, + browser: { initialUrl: url, openerTabId: null }, + } +} + +function renderView(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +beforeEach(() => { + api.bridgeOpen.mockReset() + api.bridgeClose.mockClear() + api.probeBridge.mockReset() + api.probeBridge.mockResolvedValue(true) + api.openExternalTab.mockClear() + api.copyTextToClipboard.mockClear() + api.isDesktop.mockReturnValue(false) +}) + +describe("BrowserBridgeView", () => { + it("mints a grant for the tab and shows the page in a sandboxed frame", async () => { + api.bridgeOpen.mockResolvedValue(grant) + renderView() + expect(screen.getByRole("status")).toHaveTextContent( + "Connecting through the server" + ) + const frame = (await screen.findByTitle( + "Dev server preview" + )) as HTMLIFrameElement + expect(api.bridgeOpen).toHaveBeenCalledWith( + "http://localhost:3000/docs?x=1", + "tab-1" + ) + const expectedSrc = bridgeEntryUrl(grant, window.location) + expect(frame.getAttribute("src")).toBe(expectedSrc) + expect(expectedSrc).toContain(":3081/__codeg_bridge/enter/cap-one?to=") + expect(frame.getAttribute("sandbox")).toBe(BRIDGE_FRAME_SANDBOX) + expect(frame.getAttribute("sandbox")).not.toContain("allow-top-navigation") + expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer") + expect(api.probeBridge).toHaveBeenCalledWith( + expectedSrc.slice(0, expectedSrc.indexOf("/__codeg_bridge")) + ) + // The address shown is the one the user opened, not the bridge's. + expect(screen.getByTitle("http://localhost:3000/docs?x=1")).toBeTruthy() + }) + + it("opens the same entry in a new tab and copies the address", async () => { + api.bridgeOpen.mockResolvedValue(grant) + renderView() + await screen.findByTitle("Dev server preview") + fireEvent.click(screen.getByRole("button", { name: "Open in a new tab" })) + expect(api.openExternalTab).toHaveBeenCalledWith( + bridgeEntryUrl(grant, window.location) + ) + fireEvent.click(screen.getByRole("button", { name: "Copy address" })) + await waitFor(() => + expect(api.copyTextToClipboard).toHaveBeenCalledWith( + "http://localhost:3000/docs?x=1" + ) + ) + }) + + it("reload mints a fresh grant and reloads the frame with it", async () => { + api.bridgeOpen.mockResolvedValueOnce(grant).mockResolvedValueOnce({ + ...grant, + entryPath: "/__codeg_bridge/enter/cap-two", + }) + renderView() + await screen.findByTitle("Dev server preview") + fireEvent.click(screen.getByRole("button", { name: "Reload" })) + await waitFor(() => + expect( + screen.getByTitle("Dev server preview").getAttribute("src") + ).toContain("cap-two") + ) + expect(api.bridgeOpen).toHaveBeenCalledTimes(2) + }) + + it("explains an unreachable bridge port instead of a blank frame", async () => { + api.bridgeOpen.mockResolvedValue(grant) + api.probeBridge.mockResolvedValue(false) + renderView() + await screen.findByText("The bridge port can't be reached") + expect(screen.getByRole("status")).toHaveTextContent(":3081") + expect(screen.getByRole("status")).toHaveTextContent("CODEG_BRIDGE_PORTS") + expect(screen.queryByTitle("Dev server preview")).toBeNull() + // A top-level tab may still get there (a VPN, a different route), so + // the new-tab action stays. + fireEvent.click( + screen.getAllByRole("button", { name: "Open in a new tab" })[0] + ) + expect(api.openExternalTab).toHaveBeenCalledWith( + bridgeEntryUrl(grant, window.location) + ) + }) + + it("shows the server's refusal", async () => { + api.bridgeOpen.mockRejectedValue( + new Error("192.168.1.9 is not the server's loopback") + ) + renderView() + await screen.findByText("This page can't be shown here") + expect(screen.getByRole("status")).toHaveTextContent( + "192.168.1.9 is not the server's loopback" + ) + expect( + screen.getByRole("button", { name: "Open in a new tab" }) + ).toBeDisabled() + }) + + it("releases the grant when the view goes away", async () => { + api.bridgeOpen.mockResolvedValue(grant) + const view = renderView() + await screen.findByTitle("Dev server preview") + expect(api.bridgeClose).not.toHaveBeenCalled() + await act(async () => { + view.unmount() + }) + expect(api.bridgeClose).toHaveBeenCalledWith("tab-1") + }) +}) + +describe("BrowserTabView in a browser", () => { + it("renders the bridge view, never the native surface", async () => { + api.bridgeOpen.mockResolvedValue(grant) + renderView() + await screen.findByTitle("Dev server preview") + expect(screen.queryByTestId("native-surface")).toBeNull() + }) +}) diff --git a/src/components/browser/browser-bridge-view.tsx b/src/components/browser/browser-bridge-view.tsx new file mode 100644 index 0000000000..1570b5a369 --- /dev/null +++ b/src/components/browser/browser-bridge-view.tsx @@ -0,0 +1,221 @@ +"use client" + +import { useEffect, useState } from "react" +import { Copy, ExternalLink, Loader2, RotateCw } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import type { BrowserWorkspaceTab } from "@/contexts/workspace-context" +import { + bridgeClose, + bridgeEntryUrl, + bridgeOpen, + bridgeOrigin, + probeBridge, + type BridgeGrant, +} from "@/lib/browser/browser-bridge" +import { browserTabBackendId } from "@/lib/file-tab-id" +import { openExternalTab } from "@/lib/link-open" +import { copyTextToClipboard } from "@/lib/utils" + +const ICON_BTN = + "flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-primary/8 hover:text-foreground disabled:pointer-events-none disabled:opacity-40" + +/** The frame keeps its origin (its own bridge port, shared with nothing) and + * never navigates the workbench. */ +export const BRIDGE_FRAME_SANDBOX = + "allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads" + +type Phase = + | { kind: "opening" } + | { kind: "ready"; grant: BridgeGrant; src: string } + | { kind: "unreachable"; grant: BridgeGrant; origin: string; src: string } + | { kind: "error"; message: string } + +function messageOf(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message + if (typeof message === "string" && message) return message + } + return String(error) +} + +/** + * The web-mode body of a browser tab: a dev server on the codeg host shown + * through the port bridge in an iframe. Each mount takes a fresh grant (a + * reload too), releases it on unmount, and probes the bridge port from this + * browser before showing the frame so an unreachable port is explained + * instead of left blank. "Open in a new tab" stays available throughout: + * whatever the frame cannot show, a top-level tab on the same bridge origin + * can. + */ +export function BrowserBridgeView({ tab }: { tab: BrowserWorkspaceTab }) { + const t = useTranslations("Browser.bridge") + const url = tab.browser.initialUrl + const tabId = browserTabBackendId(tab.id) ?? tab.id + const [attempt, setAttempt] = useState(0) + // The outcome is stamped with the attempt it belongs to; a new attempt + // (a reload, another address) reads as "opening" until its own outcome + // lands, without a synchronous reset in the effect. + const [outcome, setOutcome] = useState<{ + attempt: number + url: string + phase: Phase + } | null>(null) + const phase: Phase = + outcome && outcome.attempt === attempt && outcome.url === url + ? outcome.phase + : { kind: "opening" } + + useEffect(() => { + let cancelled = false + const settle = (phase: Phase) => { + if (!cancelled) setOutcome({ attempt, url, phase }) + } + void (async () => { + let grant: BridgeGrant + try { + grant = await bridgeOpen(url, tabId) + } catch (error) { + settle({ kind: "error", message: messageOf(error) }) + return + } + const page = window.location + const origin = bridgeOrigin(grant, page) + const src = bridgeEntryUrl(grant, page) + const reachable = await probeBridge(origin) + settle( + reachable + ? { kind: "ready", grant, src } + : { kind: "unreachable", grant, origin, src } + ) + })() + return () => { + cancelled = true + } + }, [url, tabId, attempt]) + + // The hold ends with the view: the listener closes a minute later unless + // another tab uses it. Coming back mints a new grant and reloads the page. + useEffect( + () => () => { + void bridgeClose(tabId).catch(() => {}) + }, + [tabId] + ) + + const src = + phase.kind === "ready" || phase.kind === "unreachable" ? phase.src : null + + const copyAddress = async () => { + const ok = await copyTextToClipboard(url) + if (ok) toast.success(t("copied")) + } + + return ( +
+
+ + {url} + + + + +
+
+ {phase.kind === "opening" ? ( +
+ + {t("opening")} +
+ ) : null} + {phase.kind === "ready" ? ( + +
fat
+
emoji
+
+
tall
+
+ +