From c098f82806d4c242bac0f63db0b11adc71d833e4 Mon Sep 17 00:00:00 2001
From: zhom <2717306+zhom@users.noreply.github.com>
Date: Sun, 4 Oct 2026 23:16:51 +0400
Subject: [PATCH] refactor: adapt recipes to remote mcp
---
.github/dependabot.yml | 11 +-
.github/workflows/issue-validation.yml | 2 +-
.github/workflows/linux-packages.yml | 4 +-
.node-version | 3 +-
.nvmrc | 2 +-
AGENTS.md | 2 +
README.md | 34 +-
assets/readme/automation.svg | 1 +
assets/readme/hero.svg | 1 +
assets/readme/linking.svg | 1 +
assets/readme/local.svg | 1 +
biome.json | 2 +-
donut-sync/Dockerfile | 4 +-
donut-sync/package.json | 16 +-
e2e/app/Cargo.lock | 448 +++---
e2e/coverage-map.mjs | 32 +-
e2e/tests/browser.test.mjs | 161 ++
e2e/tests/entities.test.mjs | 158 ++
e2e/tests/integrations.test.mjs | 230 ++-
e2e/tests/ui.test.mjs | 512 +++++--
flake.lock | 6 +-
flake.nix | 46 +-
osv-scanner.toml | 26 -
package.json | 27 +-
.../flatpak/com.donutbrowser.DonutBrowser.yml | 2 +-
pnpm-lock.yaml | 916 ++++++-----
pnpm-workspace.yaml | 21 +-
src-tauri/Cargo.lock | 78 +-
src-tauri/Cargo.toml | 11 +-
src-tauri/capabilities/default.json | 1 +
src-tauri/src/agent.rs | 1262 ----------------
src-tauri/src/agent_console.rs | 1340 +++++++++++++++++
src-tauri/src/api_server.rs | 47 +-
src-tauri/src/automation_rate_limiter.rs | 57 +
src-tauri/src/cloud_auth.rs | 69 +-
src-tauri/src/lib.rs | 33 +-
src-tauri/src/mcp_server.rs | 543 ++++++-
src-tauri/src/profile/manager.rs | 97 +-
src-tauri/src/profile/portable.rs | 5 +-
src-tauri/src/profile_importer.rs | 1 +
src-tauri/src/recipes.rs | 312 ++++
src-tauri/src/wayfern_manager.rs | 484 ++++++
src/app/page.tsx | 60 +-
src/components/agent-activity.tsx | 259 ++++
src/components/agent-console-parts.tsx | 269 ++++
src/components/agent-conversation.tsx | 913 +++++++++++
src/components/agent-page.tsx | 783 +++++++---
src/components/agent-profiles.tsx | 388 +++++
src/components/agent-recipe-recorder.tsx | 37 +-
src/components/agent-recipe-steps.tsx | 52 +-
src/components/agent-recipes.tsx | 43 +-
src/components/agent-run-form.tsx | 491 ------
src/components/agent-run-history.tsx | 244 ---
src/components/agent-run-view.tsx | 276 ----
src/components/agent-shared.tsx | 215 ---
src/components/custom-toast.tsx | 10 +
src/components/profile-info-dialog.tsx | 56 +-
src/components/rail-nav.tsx | 27 +-
src/components/tips/scenes-plan.tsx | 137 +-
src/components/wayfern-config-dialog.tsx | 6 +-
src/components/wayfern-config-form.tsx | 63 +
src/generated/licenses.json | 36 +-
src/hooks/use-agent-console.ts | 378 +++++
src/hooks/use-agent-run.ts | 225 ---
src/hooks/use-tips.ts | 6 +-
src/i18n/locales/de.json | 296 ++--
src/i18n/locales/en.json | 296 ++--
src/i18n/locales/es.json | 296 ++--
src/i18n/locales/fr.json | 296 ++--
src/i18n/locales/ja.json | 296 ++--
src/i18n/locales/ko.json | 296 ++--
src/i18n/locales/pt.json | 296 ++--
src/i18n/locales/ru.json | 296 ++--
src/i18n/locales/tr.json | 296 ++--
src/i18n/locales/vi.json | 296 ++--
src/i18n/locales/zh.json | 296 ++--
src/lib/agent-console.test.mjs | 292 ++++
src/lib/agent-console.ts | 419 ++++++
src/lib/agent.test.mjs | 190 ---
src/lib/agent.ts | 367 -----
src/lib/backend-errors.ts | 86 +-
src/lib/entitlements.ts | 35 +-
src/lib/recipes.test.mjs | 42 +
src/lib/recipes.ts | 137 ++
src/lib/tips.test.mjs | 2 -
src/lib/tips.ts | 4 +-
src/lib/toast-utils.ts | 5 +
src/types.ts | 24 +-
88 files changed, 10452 insertions(+), 6390 deletions(-)
create mode 100644 assets/readme/automation.svg
create mode 100644 assets/readme/hero.svg
create mode 100644 assets/readme/linking.svg
create mode 100644 assets/readme/local.svg
delete mode 100644 src-tauri/src/agent.rs
create mode 100644 src-tauri/src/agent_console.rs
create mode 100644 src-tauri/src/recipes.rs
create mode 100644 src/components/agent-activity.tsx
create mode 100644 src/components/agent-console-parts.tsx
create mode 100644 src/components/agent-conversation.tsx
create mode 100644 src/components/agent-profiles.tsx
delete mode 100644 src/components/agent-run-form.tsx
delete mode 100644 src/components/agent-run-history.tsx
delete mode 100644 src/components/agent-run-view.tsx
delete mode 100644 src/components/agent-shared.tsx
create mode 100644 src/hooks/use-agent-console.ts
delete mode 100644 src/hooks/use-agent-run.ts
create mode 100644 src/lib/agent-console.test.mjs
create mode 100644 src/lib/agent-console.ts
delete mode 100644 src/lib/agent.test.mjs
delete mode 100644 src/lib/agent.ts
create mode 100644 src/lib/recipes.test.mjs
create mode 100644 src/lib/recipes.ts
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e99996fd..47f077aa 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -39,6 +39,10 @@ updates:
# from 1.25 it pulls in aws-lc, which needs CMake and NASM to build.
- dependency-name: "shadowsocks"
versions: [">=1.25"]
+ # Moves with Tauri (see the note in src-tauri/Cargo.toml): two gtk-sys
+ # versions cannot both link gtk-3.
+ - dependency-name: "gtk"
+ versions: [">=0.19"]
groups:
rust-dependencies:
patterns:
@@ -60,6 +64,10 @@ updates:
# from 1.25 it pulls in aws-lc, which needs CMake and NASM to build.
- dependency-name: "shadowsocks"
versions: [">=1.25"]
+ # Moves with Tauri (see the note in src-tauri/Cargo.toml): two gtk-sys
+ # versions cannot both link gtk-3.
+ - dependency-name: "gtk"
+ versions: [">=0.19"]
groups:
e2e-rust-dependencies:
patterns:
@@ -93,8 +101,7 @@ updates:
day: "saturday"
time: "09:00"
ignore:
- # Node majors follow .node-version (the LTS line), not the newest
- # "Current" release; moving to the next LTS is done by hand.
+ # Node majors follow .node-version and are moved by hand.
- dependency-name: "node"
update-types: ["version-update:semver-major"]
groups:
diff --git a/.github/workflows/issue-validation.yml b/.github/workflows/issue-validation.yml
index 0cedb698..7c554833 100644
--- a/.github/workflows/issue-validation.yml
+++ b/.github/workflows/issue-validation.yml
@@ -662,7 +662,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
- uses: anomalyco/opencode/github@8a8bd622a3d7dc29ccf30ec17f84e363ed95ed72 #v2.0.21
+ uses: anomalyco/opencode/github@527f0b931d1f9b3ebd34e106c51b31ce5db5b075 #v2.0.22
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml
index d00fc368..b8e6ac98 100644
--- a/.github/workflows/linux-packages.yml
+++ b/.github/workflows/linux-packages.yml
@@ -149,8 +149,8 @@ jobs:
timeout-minutes: 45
container:
# flatpak, flatpak-builder, flatpak-builder-lint and appstreamcli, with
- # org.gnome.Platform//50 and org.gnome.Sdk//50 already installed.
- image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
+ # org.gnome.Platform//51 and org.gnome.Sdk//51 already installed.
+ image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-51
options: --privileged
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
diff --git a/.node-version b/.node-version
index e63d7ea4..6f4247a6 100644
--- a/.node-version
+++ b/.node-version
@@ -1,2 +1 @@
-24
-
+26
diff --git a/.nvmrc b/.nvmrc
index fce7a4d9..6f4247a6 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-24
\ No newline at end of file
+26
diff --git a/AGENTS.md b/AGENTS.md
index 8d39ba31..eaa0dffe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,6 +54,8 @@ donutbrowser/
│ │ ├── mcp_integrations.rs # 20-client MCP installer: local URL or remote endpoint with bearer, format-preserving JSONC/TOML edits
│ │ ├── mcp_migration.rs # Local-to-remote MCP move: once-per-account offer, local server switch-off, removal notice
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
+│ │ ├── agent_console.rs # The person's side of connected MCP agents: activity, questions, notes, take-over, pause
+│ │ ├── recipes.rs # Thin client for cloud recipes; storage and replay stay in the cloud
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── xray/ # VLESS + XTLS Vision + REALITY config/URI support
diff --git a/README.md b/README.md
index 1e9c7c25..7c05ac6e 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,37 @@
-
+
+
+
+
+
+ Unlimited browser profiles on one computer. Each one shows websites a different device,
+ keeps its own cookies, extensions and proxy, and stays on your machine.
+
+
+## Keep every account separate
+
+Websites link accounts that come from the same device, and then one ban can take all of them. In Donut, each account runs in its own profile with its own fingerprint, cookies and proxy or VPN, so every site sees a different device.
+
+
+
+The fingerprints come from [Wayfern](https://wayfern.com), a privacy-focused Chromium fork that sets them inside the engine, not with scripts on the page.
+
+## Your data stays on your device
+
+
+
+- No account needed: profiles, cookies, logins and history live on your computer.
+- Zero telemetry: the app sends no tracking or analytics.
+- Lock a profile with a password, or make it ephemeral so that nothing is left when it closes.
+- Sync is optional and end-to-end encrypted with a password only you know. Use Donut Sync or [run your own server](https://donutbrowser.com/docs/self-hosting).
+
+## Automate with code or with AI agents
+
+
+
+Start any profile from the local REST API on `127.0.0.1:10108`, then connect Playwright, Puppeteer or Selenium to it over CDP. Or connect Claude, Cursor, Codex or another MCP client over remote MCP, and watch what each agent does. Browser automation and remote MCP come with the paid plans. See the [quickstart](https://donutbrowser.com/docs/quickstart), [Playwright with Wayfern](https://donutbrowser.com/docs/wayfern) and [MCP](https://donutbrowser.com/docs/mcp) guides.
## Features
@@ -30,7 +60,7 @@
- DNS AdBlocker: block ads, trackers, and other unwanted content with per-profile DNS blocking
- Proxy support: HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- VPN support: WireGuard configs per profile
-- Local API & MCP: REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
+- REST API & remote MCP: a local REST API and a remote [Model Context Protocol](https://modelcontextprotocol.io) server for Claude, Cursor, automation tools and custom workflows
- Profile groups: organize profiles and apply bulk settings
- Import profiles: migrate from Chrome, Edge, Brave, or other Chromium browsers
- Cookie & extension management: import/export cookies, manage extensions per profile
diff --git a/assets/readme/automation.svg b/assets/readme/automation.svg
new file mode 100644
index 00000000..3e319efe
--- /dev/null
+++ b/assets/readme/automation.svg
@@ -0,0 +1 @@
+automate.ts 1 import { chromium } from "playwright" ; 2 3 const api = "http://127.0.0.1:10108/v1" ; 4 const headers = { Authorization: `Bearer ${token}` }; 5 6 const res = await fetch ( `${api}/profiles/${id}/run` , { 7 method: "POST" , headers, 8 }); 9 const { remote_debugging_port: port } = await res. json (); 10 11 const browser = await chromium. connectOverCDP ( 12 `http://127.0.0.1:${port}` , 13 ); 14 const [page] = browser. contexts ()[ 0 ]. pages (); 15 await page. goto ( "https://example.com" ); $ node automate.ts Store DE is running, CDP port 52114 Playwright connected over CDP example.com opened in the Store DE profile Your AI agent Remote MCP Claude, Cursor, Codex or any MCP client Open Store DE and tell me what is new on the orders page. list_profiles 8 profiles run_profile "Store DE" running navigate "/orders" loaded get_page_content 3 new rows 3 new orders since yesterday. The newest one is from Berlin. Watch it live, answer its questions, take over.
\ No newline at end of file
diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg
new file mode 100644
index 00000000..ae1ce85f
--- /dev/null
+++ b/assets/readme/hero.svg
@@ -0,0 +1 @@
+What websites see Store DE donutbrowser.com/tools/browser-fingerprint What websites see Ads BR donutbrowser.com/tools/browser-fingerprint What websites see Research donutbrowser.com/tools/browser-fingerprint All 8 Work 3 Personal 3 Research 2 Search profiles... New Name Tags Proxy / VPN Store DE store eu res-de-berlin-01 218.4 KB/s Ads BR ads res-br-sao-02 192.0 KB/s Research osint WG wg-home 243.3 KB/s Banking finance None Social No tags dc-nl-ams-04 Travel No tags res-us-nyc-07 What websites see Ads BR donutbrowser.com/tools/browser-fingerprint What websites see Platform Win32 Screen 1366×768 Timezone America/Sao_Paulo WebGL Intel UHD 620 IP address 198.51.100.73 · BR Cookies stay in this profile
\ No newline at end of file
diff --git a/assets/readme/linking.svg b/assets/readme/linking.svg
new file mode 100644
index 00000000..d7de282c
--- /dev/null
+++ b/assets/readme/linking.svg
@@ -0,0 +1 @@
+Ordinary browser Linked YOUR ACCOUNTS WHAT SITES SEE Store A Store B Ads account Brand page Win32 America/New_York 198.51.100.24 ×4 One person runs all four accounts. One ban takes down all four. Donut Browser Not linked YOUR ACCOUNTS WHAT SITES SEE Store A Store B Ads account Brand page Win32 America/New_York 198.51.100.24 MacIntel Europe/Berlin 203.0.113.71 Linux x86_64 Asia/Tokyo 192.0.2.118 Win32 America/Sao_Paulo 203.0.113.201 Four different people. A ban stays with one account.
\ No newline at end of file
diff --git a/assets/readme/local.svg b/assets/readme/local.svg
new file mode 100644
index 00000000..7bd1990f
--- /dev/null
+++ b/assets/readme/local.svg
@@ -0,0 +1 @@
+Your device No account needed Store DE Cookies Logins History Fingerprint Banking Password-locked, encrypted on disk Cookies Logins History Fingerprint Research Ephemeral: gone when it closes Cookies Logins History Fingerprint a91f… 7c0e… Sync (optional) Your own server or Donut Sync End-to-end encrypted: it gets ciphertext only. Your password stays here App telemetry 0 B sent by the app. No tracking.
\ No newline at end of file
diff --git a/biome.json b/biome.json
index 334aec22..0eb4bd1a 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
diff --git a/donut-sync/Dockerfile b/donut-sync/Dockerfile
index ee71626a..d52252fb 100644
--- a/donut-sync/Dockerfile
+++ b/donut-sync/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:24-alpine AS builder
+FROM node:26-alpine AS builder
WORKDIR /build
COPY donut-sync/package.json donut-sync/tsconfig.json donut-sync/tsconfig.build.json ./
@@ -7,7 +7,7 @@ RUN npm install
RUN npm run build
RUN npm prune --omit=dev
-FROM node:24-alpine
+FROM node:26-alpine
WORKDIR /app
COPY --from=builder /build/package.json .
diff --git a/donut-sync/package.json b/donut-sync/package.json
index cca011bc..ec259af3 100644
--- a/donut-sync/package.json
+++ b/donut-sync/package.json
@@ -18,12 +18,12 @@
"test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config ./test/jest-e2e.json"
},
"dependencies": {
- "@aws-sdk/client-s3": "^3.1143.0",
- "@aws-sdk/s3-request-presigner": "^3.1143.0",
- "@nestjs/common": "^12.1.1",
+ "@aws-sdk/client-s3": "^3.1145.0",
+ "@aws-sdk/s3-request-presigner": "^3.1145.0",
+ "@nestjs/common": "^12.1.2",
"@nestjs/config": "^12.0.1",
- "@nestjs/core": "^12.1.1",
- "@nestjs/platform-express": "^12.1.1",
+ "@nestjs/core": "^12.1.2",
+ "@nestjs/platform-express": "^12.1.2",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
@@ -31,15 +31,15 @@
"devDependencies": {
"@nestjs/cli": "^12.0.8",
"@nestjs/schematics": "^12.0.6",
- "@nestjs/testing": "^12.1.1",
+ "@nestjs/testing": "^12.1.2",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
- "@types/node": "^26.6.3",
+ "@types/node": "^26.6.4",
"@types/supertest": "^7.2.1",
"jest": "^30.5.2",
"source-map-support": "^0.5.21",
- "supertest": "^7.3.0",
+ "supertest": "^7.3.1",
"ts-jest": "^29.4.14",
"ts-loader": "^9.6.2",
"ts-node": "^10.9.2",
diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock
index 95c566a5..ff93e125 100644
--- a/e2e/app/Cargo.lock
+++ b/e2e/app/Cargo.lock
@@ -107,15 +107,15 @@ dependencies = [
[[package]]
name = "alloc-no-stdlib"
-version = "2.0.4"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
-version = "0.2.4"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
@@ -360,13 +360,13 @@ dependencies = [
[[package]]
name = "async-recursion"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -723,9 +723,9 @@ dependencies = [
[[package]]
name = "brotli"
-version = "8.0.4"
+version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
+checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -734,9 +734,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
-version = "5.0.3"
+version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
+checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -900,12 +900,13 @@ dependencies = [
[[package]]
name = "cargo_toml"
-version = "0.22.3"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9"
dependencies = [
+ "semver",
"serde",
- "toml 0.9.12+spec-1.1.0",
+ "toml 1.1.6+spec-1.1.0",
]
[[package]]
@@ -919,9 +920,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.5.1"
+version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -937,9 +938,9 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfb"
-version = "0.7.3"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"
dependencies = [
"byteorder",
"fnv",
@@ -948,13 +949,13 @@ dependencies = [
[[package]]
name = "cfb"
-version = "0.10.0"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"
+checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
dependencies = [
- "byteorder",
"fnv",
"uuid",
+ "web-time",
]
[[package]]
@@ -1372,9 +1373,9 @@ dependencies = [
[[package]]
name = "cssparser"
-version = "0.36.0"
+version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros",
"dtoa-short",
@@ -1385,29 +1386,19 @@ dependencies = [
[[package]]
name = "cssparser-macros"
-version = "0.6.1"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
name = "ctor"
-version = "0.8.0"
+version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
-dependencies = [
- "ctor-proc-macro",
- "dtor",
-]
-
-[[package]]
-name = "ctor-proc-macro"
-version = "0.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
[[package]]
name = "ctr"
@@ -1622,15 +1613,6 @@ dependencies = [
"dirs-sys",
]
-[[package]]
-name = "dirs"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
-dependencies = [
- "dirs-sys",
-]
-
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1709,9 +1691,9 @@ dependencies = [
[[package]]
name = "dom_query"
-version = "0.27.0"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
+checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc"
dependencies = [
"bit-set",
"cssparser",
@@ -1743,7 +1725,7 @@ dependencies = [
"core-foundation 0.10.1",
"crossbeam-channel",
"directories",
- "dirs 7.0.0",
+ "dirs",
"dunce",
"env_logger",
"flate2",
@@ -1853,21 +1835,6 @@ dependencies = [
"dtoa",
]
-[[package]]
-name = "dtor"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
-dependencies = [
- "dtor-proc-macro",
-]
-
-[[package]]
-name = "dtor-proc-macro"
-version = "0.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
-
[[package]]
name = "dunce"
version = "1.0.5"
@@ -2226,9 +2193,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
-version = "0.12.5"
+version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
+checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
dependencies = [
"bytemuck",
]
@@ -2990,9 +2957,9 @@ dependencies = [
[[package]]
name = "html5ever"
-version = "0.38.0"
+version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
@@ -3129,7 +3096,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
- "windows-registry 0.6.1",
+ "windows-registry",
]
[[package]]
@@ -3347,11 +3314,11 @@ dependencies = [
[[package]]
name = "infer"
-version = "0.19.0"
+version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4"
dependencies = [
- "cfb 0.7.3",
+ "cfb 0.14.0",
]
[[package]]
@@ -3602,30 +3569,30 @@ dependencies = [
[[package]]
name = "json-patch"
-version = "3.0.1"
+version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72"
dependencies = [
"jsonptr",
"serde",
"serde_json",
- "thiserror 1.0.69",
+ "thiserror 2.0.21",
]
[[package]]
name = "jsonc-parser"
-version = "0.33.2"
+version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ff5a48f48971be8e762a6ff955725a0802b6e46c441057992da5a673db9fd3a"
+checksum = "0ecded084f9b9a718d39668a42657c32253663d1f6afb3fbb9134f0305f73f48"
dependencies = [
"serde_json",
]
[[package]]
name = "jsonptr"
-version = "0.6.3"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe"
dependencies = [
"serde",
"serde_json",
@@ -3633,13 +3600,12 @@ dependencies = [
[[package]]
name = "keyboard-types"
-version = "0.7.0"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
- "unicode-segmentation",
]
[[package]]
@@ -3656,9 +3622,9 @@ dependencies = [
[[package]]
name = "lazy_static"
-version = "1.5.0"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "lebe"
@@ -3698,9 +3664,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
@@ -3827,9 +3793,9 @@ checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]]
name = "markup5ever"
-version = "0.38.0"
+version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
@@ -3945,9 +3911,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.2.3"
+version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -3990,9 +3956,9 @@ dependencies = [
[[package]]
name = "muda"
-version = "0.19.3"
+version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -4047,6 +4013,12 @@ 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"
@@ -5770,9 +5742,9 @@ dependencies = [
[[package]]
name = "selectors"
-version = "0.36.1"
+version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
+checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags 2.13.2",
"cssparser",
@@ -5799,9 +5771,9 @@ dependencies = [
[[package]]
name = "sendfd"
-version = "0.4.4"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b"
+checksum = "c8fa82bbd0ce6c16273ec6c310d3973f8b8f8665ecfe0ca02a49b3ea61b2418c"
dependencies = [
"libc",
"tokio",
@@ -5927,9 +5899,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.23.0"
+version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
+checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
@@ -5948,9 +5920,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.23.0"
+version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
+checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling",
"proc-macro2",
@@ -6525,9 +6497,9 @@ dependencies = [
[[package]]
name = "tao"
-version = "0.35.3"
+version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
+checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [
"bitflags 2.13.2",
"block2",
@@ -6545,20 +6517,20 @@ dependencies = [
"libc",
"log",
"ndk",
+ "ndk-context",
"ndk-sys",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-ui-kit",
- "once_cell",
"parking_lot",
"percent-encoding",
"raw-window-handle",
"tao-macros",
"unicode-segmentation",
"url",
- "windows 0.61.3",
- "windows-core 0.61.2",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
"windows-version",
"x11-dl",
]
@@ -6599,14 +6571,14 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
-version = "2.11.6"
+version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
+checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
dependencies = [
"anyhow",
"bytes",
"cookie",
- "dirs 6.0.0",
+ "dirs",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -6644,20 +6616,20 @@ dependencies = [
"tray-icon",
"url",
"webkit2gtk",
- "webview2-com",
+ "webview2-com 0.39.1",
"window-vibrancy",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
name = "tauri-build"
-version = "2.6.3"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
+checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
dependencies = [
"anyhow",
"cargo_toml",
- "dirs 6.0.0",
+ "dirs",
"glob",
"heck 0.5.0",
"json-patch",
@@ -6672,16 +6644,16 @@ dependencies = [
[[package]]
name = "tauri-codegen"
-version = "2.6.3"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
+checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80"
dependencies = [
"base64 0.22.1",
"brotli",
"ico",
"json-patch",
"plist",
- "png 0.17.16",
+ "png 0.18.1",
"proc-macro2",
"quote",
"semver",
@@ -6699,9 +6671,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
-version = "2.6.3"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
+checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6713,9 +6685,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
-version = "2.6.3"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
+checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2"
dependencies = [
"anyhow",
"glob",
@@ -6729,9 +6701,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
-version = "2.3.3"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194"
+checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a"
dependencies = [
"arboard",
"log",
@@ -6744,9 +6716,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
-version = "2.4.10"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6"
+checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736"
dependencies = [
"dunce",
"plist",
@@ -6759,15 +6731,15 @@ dependencies = [
"thiserror 2.0.21",
"tracing",
"url",
- "windows-registry 0.5.3",
- "windows-result 0.3.4",
+ "windows-registry",
+ "windows-result 0.4.1",
]
[[package]]
name = "tauri-plugin-dialog"
-version = "2.7.3"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229"
+checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
dependencies = [
"log",
"raw-window-handle",
@@ -6783,9 +6755,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
-version = "2.5.2"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
+checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb"
dependencies = [
"anyhow",
"dunce",
@@ -6807,9 +6779,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
-version = "2.9.2"
+version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86f2e3c40b77fd1eb7f8e1b6900fb036aee2da1ea2d3783c91255a82c636649d"
+checksum = "850d1e8f27d96203b7e6e6da1a2bf034c10f465b64a17c97e06940c9006385bf"
dependencies = [
"android_logger",
"fern",
@@ -6843,9 +6815,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
-version = "2.5.5"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
+checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345"
dependencies = [
"dunce",
"glob",
@@ -6859,15 +6831,15 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.21",
"url",
- "windows 0.61.3",
+ "windows 0.62.2",
"zbus",
]
[[package]]
name = "tauri-plugin-shell"
-version = "2.3.6"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8548af174c5516e4f71f142acea4d02e00316296ea9aafa58798851481003e3c"
+checksum = "094bdbc47e5efe36544ba21b3a53ab5c724150236cf55e6c9be46191deb6eab6"
dependencies = [
"encoding_rs",
"log",
@@ -6886,9 +6858,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
-version = "2.4.5"
+version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
+checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
dependencies = [
"serde",
"serde_json",
@@ -6896,15 +6868,15 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
"zbus",
]
[[package]]
name = "tauri-plugin-window-state"
-version = "2.4.1"
+version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
+checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3"
dependencies = [
"bitflags 2.13.2",
"log",
@@ -6917,9 +6889,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
-version = "2.11.3"
+version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
+checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
dependencies = [
"cookie",
"dpi",
@@ -6936,15 +6908,15 @@ dependencies = [
"thiserror 2.0.21",
"url",
"webkit2gtk",
- "webview2-com",
- "windows 0.61.3",
+ "webview2-com 0.39.1",
+ "windows 0.62.2",
]
[[package]]
name = "tauri-runtime-wry"
-version = "2.11.4"
+version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
+checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [
"gtk",
"http",
@@ -6961,16 +6933,16 @@ dependencies = [
"tauri-utils",
"url",
"webkit2gtk",
- "webview2-com",
- "windows 0.61.3",
+ "webview2-com 0.39.1",
+ "windows 0.62.2",
"wry",
]
[[package]]
name = "tauri-utils"
-version = "2.9.3"
+version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
+checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
dependencies = [
"anyhow",
"brotli",
@@ -7038,7 +7010,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"webkit2gtk",
- "webview2-com",
+ "webview2-com 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -7224,9 +7196,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -7263,9 +7235,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
-version = "0.26.5"
+version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
@@ -7340,21 +7312,6 @@ dependencies = [
"toml_edit 0.20.2",
]
-[[package]]
-name = "toml"
-version = "0.9.12+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
-dependencies = [
- "indexmap 2.14.2",
- "serde_core",
- "serde_spanned 1.1.1",
- "toml_datetime 0.7.5+spec-1.1.0",
- "toml_parser",
- "toml_writer",
- "winnow 0.7.15",
-]
-
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
@@ -7379,15 +7336,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "toml_datetime"
-version = "0.7.5+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
-dependencies = [
- "serde_core",
-]
-
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -7598,12 +7546,12 @@ dependencies = [
[[package]]
name = "tray-icon"
-version = "0.24.2"
+version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
+checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [
"crossbeam-channel",
- "dirs 6.0.0",
+ "dirs",
"libappindicator",
"muda",
"objc2",
@@ -7697,47 +7645,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "unic-char-property"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
-dependencies = [
- "unic-char-range",
-]
-
-[[package]]
-name = "unic-char-range"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
-
-[[package]]
-name = "unic-common"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
-
-[[package]]
-name = "unic-ucd-ident"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
-dependencies = [
- "unic-char-property",
- "unic-char-range",
- "unic-ucd-version",
-]
-
-[[package]]
-name = "unic-ucd-version"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
-dependencies = [
- "unic-common",
-]
-
[[package]]
name = "unicase"
version = "2.9.0"
@@ -7827,13 +7734,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
-version = "0.3.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b"
dependencies = [
+ "icu_properties",
"regex",
"serde",
- "unic-ucd-ident",
"url",
]
@@ -7916,9 +7823,9 @@ dependencies = [
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -8164,6 +8071,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "web_atoms"
version = "0.2.6"
@@ -8227,13 +8144,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
- "webview2-com-sys",
+ "webview2-com-sys 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
]
+[[package]]
+name = "webview2-com"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys 0.39.1",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+]
+
[[package]]
name = "webview2-com-macros"
version = "0.8.1"
@@ -8256,6 +8185,17 @@ dependencies = [
"windows-core 0.61.2",
]
+[[package]]
+name = "webview2-com-sys"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb"
+dependencies = [
+ "thiserror 2.0.21",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -8295,16 +8235,17 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "window-vibrancy"
-version = "0.6.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580"
dependencies = [
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
+ "objc2-quartz-core",
"raw-window-handle",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
"windows-version",
]
@@ -8453,17 +8394,6 @@ dependencies = [
"windows-link 0.2.1",
]
-[[package]]
-name = "windows-registry"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
-dependencies = [
- "windows-link 0.1.3",
- "windows-result 0.3.4",
- "windows-strings 0.4.2",
-]
-
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -8778,12 +8708,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "winnow"
-version = "0.7.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
-
[[package]]
name = "winnow"
version = "1.0.4"
@@ -8821,9 +8745,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
-version = "0.9.3"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
@@ -8845,15 +8769,15 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wry"
-version = "0.55.1"
+version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
+checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
dependencies = [
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
- "dirs 6.0.0",
+ "dirs",
"dom_query",
"dpi",
"dunce",
@@ -8880,9 +8804,9 @@ dependencies = [
"url",
"webkit2gtk",
"webkit2gtk-sys",
- "webview2-com",
- "windows 0.61.3",
- "windows-core 0.61.2",
+ "webview2-com 0.39.1",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
"windows-version",
"x11-dl",
]
@@ -8972,9 +8896,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs
index d4d02743..8099a422 100644
--- a/e2e/coverage-map.mjs
+++ b/e2e/coverage-map.mjs
@@ -337,21 +337,29 @@ export const commandCoverage = {
"cookie_bot::delete_cookie_bot_user_template",
],
},
- agent: {
+ recipes: {
suite: "integrations",
level: "contract",
commands: [
- "agent::start_agent_run",
- "agent::get_agent_runs",
- "agent::get_agent_run",
- "agent::cancel_agent_run",
- "agent::get_agent_recipes",
- "agent::create_agent_recipe",
- "agent::update_agent_recipe",
- "agent::delete_agent_recipe",
- "agent::start_agent_run_events",
- "agent::stop_agent_run_events",
- "agent::get_agent_run_events_status",
+ "recipes::get_agent_recipes",
+ "recipes::create_agent_recipe",
+ "recipes::update_agent_recipe",
+ "recipes::delete_agent_recipe",
+ ],
+ },
+ agentConsole: {
+ suite: "integrations",
+ level: "integration",
+ commands: [
+ "agent_console::get_agent_console",
+ "agent_console::answer_agent_request",
+ "agent_console::dismiss_agent_request",
+ "agent_console::send_agent_note",
+ "agent_console::take_over_profile",
+ "agent_console::hand_back_profile",
+ "agent_console::set_agents_paused",
+ "agent_console::clear_agent_activity",
+ "agent_console::show_profile_window",
],
},
updateContracts: {
diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs
index e9cb8079..908b03d1 100644
--- a/e2e/tests/browser.test.mjs
+++ b/e2e/tests/browser.test.mjs
@@ -48,6 +48,25 @@ function processExists(pid) {
}
}
+function processCommandLine(pid) {
+ assert.ok(Number.isInteger(pid) && pid > 0);
+ if (process.platform === "win32") {
+ return execFileSync(
+ "powershell.exe",
+ [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`,
+ ],
+ { encoding: "utf8" },
+ );
+ }
+ return execFileSync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
+ encoding: "utf8",
+ });
+}
+
async function waitForProcessExit(app, pid) {
await app.waitFor(() => !processExists(pid), {
timeoutMs: 20_000,
@@ -1641,3 +1660,145 @@ test("a payload profile an older version stored launches and is stored as an ide
await app.close();
}
});
+
+test("custom launch arguments reach only their Wayfern profile", async () => {
+ assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
+ const app = appFromEnvironment("browser-launch-args", {
+ seedVersionCache:
+ cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false,
+ wayfernTermsAccepted: false,
+ });
+ let cdp;
+ try {
+ const prepared = await prepareWayfern(
+ app,
+ process.env.DONUT_E2E_PROJECT_ROOT,
+ );
+ if (!app.session) await app.start();
+ await app.invoke("accept_wayfern_terms");
+ const profile = await createRealProfile(
+ app,
+ prepared.version,
+ "Launch Arguments",
+ );
+ const other = await createRealProfile(
+ app,
+ prepared.version,
+ "Default Arguments",
+ );
+ const custom = [
+ "--mute-audio",
+ "--disable-gpu",
+ "--renderer-process-limit=4",
+ "--disable-features=DonutLaunchArgsTest",
+ ];
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...profile.wayfern_config, launch_args: custom },
+ });
+ const stored = (await app.invoke("list_browser_profiles")).find(
+ (p) => p.id === profile.id,
+ );
+ const settings = await app.invoke("get_app_settings");
+ const savedSettings = await app.invoke("save_app_settings", {
+ settings: {
+ ...settings,
+ api_enabled: true,
+ api_port: 0,
+ api_token: null,
+ },
+ });
+ const port = await app.invoke("start_api_server", { port: 0 });
+ const launch = async () => {
+ const result = await request(
+ `http://127.0.0.1:${port}/v1/profiles/${profile.id}/run`,
+ {
+ method: "POST",
+ token: savedSettings.api_token,
+ body: { url: "about:blank", headless: true },
+ },
+ );
+ assert.equal(result.response.status, 200, JSON.stringify(result.value));
+ return result.value;
+ };
+ const stop = async () => {
+ const current = (await app.invoke("list_browser_profiles")).find(
+ (p) => p.id === profile.id,
+ );
+ await app.invoke("kill_browser_profile", { profile: current });
+ await waitForProcessExit(app, current.process_id);
+ };
+ const launched = await launch();
+ cdp = await CdpClient.connect(launched.remote_debugging_port);
+ const running = (await app.invoke("list_browser_profiles")).find(
+ (p) => p.id === profile.id,
+ );
+ // Browser.getBrowserCommandLine needs --enable-automation, which a profile
+ // may not set, so the OS command line is what is checked.
+ const processArgs = processCommandLine(running.process_id);
+ for (const argument of custom.filter(
+ (arg) => !arg.startsWith("--disable-features="),
+ )) {
+ assert.ok(processArgs.includes(argument), argument);
+ }
+ const disabled = processArgs.match(/--disable-features=(\S+)/g) ?? [];
+ assert.equal(disabled.length, 1, "one merged --disable-features switch");
+ assert.ok(disabled[0].includes("DonutLaunchArgsTest"));
+ assert.ok(disabled[0].includes("AsyncDns"));
+ assert.ok(disabled[0].includes("Prefetch"));
+ assert.ok(
+ processArgs.includes(
+ `--user-data-dir=${path.join(app.dataRoot, "data", "profiles", profile.id, "profile")}`,
+ ),
+ );
+ assert.match(
+ await app.invokeError("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...stored.wayfern_config, launch_args: [] },
+ }),
+ /running|PROFILE_RUNNING/i,
+ );
+ cdp.close();
+ cdp = null;
+ await stop();
+ assert.deepEqual(
+ (await app.invoke("list_browser_profiles")).find((p) => p.id === other.id)
+ .wayfern_config.launch_args ?? [],
+ [],
+ );
+
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: {
+ ...stored.wayfern_config,
+ launch_args: ["--mute-audio"],
+ },
+ });
+ const relaunched = await launch();
+ cdp = await CdpClient.connect(relaunched.remote_debugging_port);
+ const current = (await app.invoke("list_browser_profiles")).find(
+ (p) => p.id === profile.id,
+ );
+ assert.ok(
+ !processCommandLine(current.process_id).includes("--disable-gpu"),
+ );
+ assert.ok(processCommandLine(current.process_id).includes("--mute-audio"));
+ cdp.close();
+ cdp = null;
+ await stop();
+ } catch (error) {
+ await app.capture("failure");
+ throw error;
+ } finally {
+ cdp?.close();
+ if (app.session) {
+ const profiles = await app
+ .invoke("list_browser_profiles")
+ .catch(() => []);
+ for (const profile of profiles.filter((p) => p.process_id)) {
+ await app.invoke("kill_browser_profile", { profile }).catch(() => {});
+ }
+ }
+ await app.close();
+ }
+});
diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs
index 5334a757..0627713c 100644
--- a/e2e/tests/entities.test.mjs
+++ b/e2e/tests/entities.test.mjs
@@ -236,6 +236,22 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
profileId: profile.id,
newName: "Renamed Profile",
});
+ // Its own name, another case, or extra spaces are not a conflict.
+ for (const [newName, stored] of [
+ ["Renamed Profile", "Renamed Profile"],
+ [" renamed profile ", "renamed profile"],
+ ["Renamed Profile ", "Renamed Profile"],
+ ]) {
+ assert.equal(
+ (
+ await app.invoke("rename_profile", {
+ profileId: profile.id,
+ newName,
+ })
+ ).name,
+ stored,
+ );
+ }
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
@@ -1642,3 +1658,145 @@ test("proxies distribute one to one, and group bookmarks reach the profile's Boo
}
});
});
+
+test("custom launch arguments require Pro and can be cleared after access ends", async () => {
+ await withApp(
+ "entities-launch-args-free",
+ async (app) => {
+ const profile = await createProfile(app, "Free Launch Arguments");
+ const error = await app.invokeError("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...profile.wayfern_config, launch_args: ["--disable-gpu"] },
+ });
+ assert.match(error, /"code":"WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"/);
+ assert.deepEqual(
+ (await app.invoke("list_browser_profiles"))[0].wayfern_config
+ .launch_args ?? [],
+ [],
+ );
+ const createError = await app.invokeError("create_browser_profile_new", {
+ name: "Refused Launch Arguments",
+ browserStr: "wayfern",
+ version: profile.version,
+ releaseType: "stable",
+ wayfernConfig: { fingerprint: "{}", launch_args: ["--disable-gpu"] },
+ });
+ assert.match(createError, /"code":"WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"/);
+ assert.equal((await app.invoke("list_browser_profiles")).length, 1);
+
+ const metadata = path.join(
+ app.dataRoot,
+ "data",
+ "profiles",
+ profile.id,
+ "metadata.json",
+ );
+ const stored = JSON.parse(await readFile(metadata, "utf8"));
+ stored.wayfern_config.launch_args = ["--disable-gpu"];
+ stored.updated_at = 1;
+ await writeFile(metadata, JSON.stringify(stored));
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...stored.wayfern_config, launch_args: [] },
+ });
+ const cleared = (await app.invoke("list_browser_profiles"))[0];
+ assert.deepEqual(cleared.wayfern_config.launch_args ?? [], []);
+ assert.ok(cleared.updated_at > 1);
+ assert.equal(
+ cleared.wayfern_config.fingerprint,
+ profile.wayfern_config.fingerprint,
+ );
+ assert.match(
+ await app.invokeError("update_wayfern_config", {
+ profileId: profile.id,
+ config: {
+ ...cleared.wayfern_config,
+ fingerprint: '{"userAgent":"changed"}',
+ },
+ }),
+ /"code":"FINGERPRINT_REQUIRES_PRO"/,
+ );
+ },
+ { extraEnv: { WAYFERN_TEST_TOKEN: "" } },
+ );
+});
+
+test("Pro launch arguments persist, clone, validate, and update the edit time", async () => {
+ await withApp(
+ "entities-launch-args-pro",
+ async (app) => {
+ const profile = await createProfile(app, "Pro Launch Arguments");
+ const metadata = path.join(
+ app.dataRoot,
+ "data",
+ "profiles",
+ profile.id,
+ "metadata.json",
+ );
+ const stored = JSON.parse(await readFile(metadata, "utf8"));
+ stored.updated_at = 1;
+ await writeFile(metadata, JSON.stringify(stored));
+ const custom = [
+ "--disable-gpu",
+ "--autoplay-policy=no-user-gesture-required",
+ ];
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: {
+ ...stored.wayfern_config,
+ launch_args: [" --disable-gpu ", "", custom[1]],
+ },
+ });
+ const saved = JSON.parse(await readFile(metadata, "utf8"));
+ assert.deepEqual(saved.wayfern_config.launch_args, custom);
+ assert.ok(saved.updated_at > 1);
+ const cloned = await app.invoke("clone_profile", {
+ profileId: profile.id,
+ name: "Cloned Launch Arguments",
+ });
+ assert.deepEqual(cloned.wayfern_config.launch_args, custom);
+ saved.updated_at = 2;
+ await writeFile(metadata, JSON.stringify(saved));
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: saved.wayfern_config,
+ });
+ assert.equal(JSON.parse(await readFile(metadata, "utf8")).updated_at, 2);
+ for (const [arg, code] of [
+ ["--user-data-dir=/tmp/other", "WAYFERN_LAUNCH_ARG_RESERVED"],
+ ["--window-size 800,600", "WAYFERN_LAUNCH_ARG_INVALID"],
+ ["--renderer-cmd-prefix=/tmp/x", "WAYFERN_LAUNCH_ARG_NOT_ALLOWED"],
+ ["--renderer-process-limit=999", "WAYFERN_LAUNCH_ARG_VALUE_INVALID"],
+ ]) {
+ assert.match(
+ await app.invokeError("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...saved.wayfern_config, launch_args: [arg] },
+ }),
+ new RegExp(`"code":"${code}"`),
+ );
+ assert.deepEqual(
+ JSON.parse(await readFile(metadata, "utf8")).wayfern_config
+ .launch_args,
+ custom,
+ );
+ }
+ await app.invoke("update_wayfern_config", {
+ profileId: profile.id,
+ config: { ...saved.wayfern_config, launch_args: [] },
+ });
+ assert.deepEqual(
+ JSON.parse(await readFile(metadata, "utf8")).wayfern_config
+ .launch_args ?? [],
+ [],
+ );
+ assert.deepEqual(
+ (await app.invoke("list_browser_profiles")).find(
+ (p) => p.id === cloned.id,
+ ).wayfern_config.launch_args,
+ custom,
+ );
+ },
+ { extraEnv: { WAYFERN_TEST_TOKEN: "e2e-local-launch-args" } },
+ );
+});
diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs
index 2ce394c0..ac7d6c8c 100644
--- a/e2e/tests/integrations.test.mjs
+++ b/e2e/tests/integrations.test.mjs
@@ -859,6 +859,162 @@ test("local MCP is removed: enabling it and installing a local client are refuse
});
});
+test("the agent console keeps notes, take-overs and the pause for connected agents", async () => {
+ await withApp("integrations-agent-console", async (app) => {
+ await seedTerms(app);
+ const empty = await app.invoke("get_agent_console");
+ assert.deepEqual(empty.sessions, []);
+ assert.deepEqual(empty.activity, []);
+ assert.deepEqual(empty.thread, []);
+ assert.deepEqual(empty.holds, []);
+ assert.equal(empty.paused, null);
+
+ const note = await app.invoke("send_agent_note", {
+ text: " Use the EU proxies today ",
+ });
+ assert.equal(note.kind, "note");
+ assert.equal(note.state, "pending");
+ assert.equal(note.text, "Use the EU proxies today");
+ assert.equal(note.session_id, null);
+ await assertCommandErrorCode(app, "send_agent_note", "AGENT_NOTE_EMPTY", {
+ text: " ",
+ });
+ await assertCommandErrorCode(
+ app,
+ "send_agent_note",
+ "AGENT_TEXT_TOO_LONG",
+ {
+ text: "x".repeat(2001),
+ },
+ );
+ await assertCommandErrorCode(
+ app,
+ "send_agent_note",
+ "AGENT_SESSION_NOT_FOUND",
+ {
+ text: "hello",
+ sessionId: "missing-e2e-session",
+ },
+ );
+
+ await assertCommandErrorCode(
+ app,
+ "answer_agent_request",
+ "AGENT_REQUEST_NOT_FOUND",
+ {
+ requestId: 987654,
+ answer: "yes",
+ },
+ );
+ await assertCommandErrorCode(
+ app,
+ "dismiss_agent_request",
+ "AGENT_REQUEST_NOT_FOUND",
+ {
+ requestId: 987654,
+ },
+ );
+
+ const missingProfileId = "00000000-0000-4000-8000-00000000e2e0";
+ await assertCommandErrorCode(
+ app,
+ "take_over_profile",
+ "PROFILE_NOT_FOUND",
+ {
+ profileId: missingProfileId,
+ },
+ );
+ await assertCommandErrorCode(
+ app,
+ "show_profile_window",
+ "PROFILE_NOT_FOUND",
+ {
+ profileId: missingProfileId,
+ },
+ );
+
+ const profile = await app.invoke("create_browser_profile_new", {
+ name: "Agent Console Profile",
+ browserStr: "wayfern",
+ version: "150.0.7871.100",
+ releaseType: "stable",
+ proxyId: null,
+ vpnId: null,
+ wayfernConfig: { fingerprint: "{}" },
+ groupId: null,
+ ephemeral: false,
+ dnsBlocklist: null,
+ launchHook: null,
+ });
+ await assertCommandErrorCode(
+ app,
+ "show_profile_window",
+ "PROFILE_NOT_RUNNING",
+ {
+ profileId: profile.id,
+ },
+ );
+
+ const hold = await app.invoke("take_over_profile", {
+ profileId: profile.id,
+ note: "Checking the payment page",
+ });
+ assert.equal(hold.profile_id, profile.id);
+ assert.equal(hold.note, "Checking the payment page");
+ assert.equal(hold.request_id, null);
+ // A second take-over keeps the hold and updates its note.
+ await app.invoke("take_over_profile", {
+ profileId: profile.id,
+ note: "Still here",
+ });
+ let state = await app.invoke("get_agent_console");
+ assert.deepEqual(
+ state.holds.map((h) => [h.profile_id, h.note]),
+ [[profile.id, "Still here"]],
+ );
+
+ await app.invoke("hand_back_profile", {
+ profileId: profile.id,
+ note: "Done, you can continue",
+ });
+ state = await app.invoke("get_agent_console");
+ assert.deepEqual(state.holds, []);
+ const handBackNote = state.thread.find(
+ (item) => item.kind === "note" && item.text === "Done, you can continue",
+ );
+ assert.ok(
+ handBackNote,
+ "handing back with a note leaves the note for the agents",
+ );
+ assert.equal(handBackNote.profile_id, profile.id);
+ // Handing back a profile nobody holds is a no-op.
+ await app.invoke("hand_back_profile", { profileId: profile.id });
+
+ const pause = await app.invoke("set_agents_paused", {
+ paused: true,
+ note: "Lunch break",
+ });
+ assert.equal(pause.note, "Lunch break");
+ assert.equal(
+ (await app.invoke("get_agent_console")).paused.note,
+ "Lunch break",
+ );
+ assert.equal(
+ await app.invoke("set_agents_paused", { paused: false }),
+ null,
+ );
+ assert.equal((await app.invoke("get_agent_console")).paused, null);
+
+ await app.invoke("clear_agent_activity");
+ state = await app.invoke("get_agent_console");
+ assert.deepEqual(state.activity, []);
+ assert.ok(
+ state.thread.some((item) => item.text === "Use the EU proxies today"),
+ "clearing activity keeps the conversation",
+ );
+ });
+});
+
test("the remote-control bridge refuses a signed-out desktop and stays off", async () => {
await withApp("integrations-mcp-remote", async (app) => {
await seedTerms(app);
@@ -1459,23 +1615,7 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
/"code":"PROFILE_NOT_FOUND"/,
);
- // The agent plane is brokered by the same cloud. Signed out, every read
- // and write must refuse as a translatable code, and the two things the
- // desktop can judge for itself — is this profile here, does the goal say
- // anything — must be judged BEFORE any of that, so a bad request never
- // becomes a model bill.
- assert.match(
- await app.invokeError("get_agent_runs", { limit: 5 }),
- notSignedIn,
- );
- assert.match(
- await app.invokeError("get_agent_run", { runId: "missing-e2e-run" }),
- notSignedIn,
- );
- assert.match(
- await app.invokeError("cancel_agent_run", { runId: "missing-e2e-run" }),
- notSignedIn,
- );
+ // Recipes live in the cloud; signed out, every call refuses with a code.
assert.match(await app.invokeError("get_agent_recipes"), notSignedIn);
assert.match(
await app.invokeError("create_agent_recipe", {
@@ -1498,32 +1638,6 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
}),
notSignedIn,
);
-
- // A goal is judged before the profile is even looked up: an empty goal is
- // the one refusal that costs nothing to make locally, and it must not
- // depend on being signed in.
- assert.match(
- await app.invokeError("start_agent_run", {
- input: {
- profileId: missingProfileId,
- target: "desktop",
- goal: " ",
- },
- }),
- /"code":"AGENT_GOAL_INVALID"/,
- );
- assert.match(
- await app.invokeError("start_agent_run", {
- input: {
- profileId: missingProfileId,
- target: "desktop",
- goal: "Open the dashboard and export last week's report",
- },
- }),
- /"code":"PROFILE_NOT_FOUND"/,
- );
- // Recipes are validated the same way, and with the code the rest of the
- // app already uses for a blank name rather than an agent-specific one.
assert.match(
await app.invokeError("create_agent_recipe", {
name: " ",
@@ -1531,9 +1645,6 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
}),
/"code":"NAME_CANNOT_BE_EMPTY"/,
);
- // A step is an object the API validates, never a line of prose: the old
- // string shape is refused here rather than sent and rejected by the
- // server. So is a step that names an element without saying which.
for (const steps of [
[],
["open the dashboard"],
@@ -1552,33 +1663,6 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
);
}
- // The step stream is how the run panel fills; without it a page opened
- // during a run shows a goal and nothing else. It has to start, name the
- // run it is watching, follow a switch to another run, and stop on demand.
- assert.equal(await app.invoke("get_agent_run_events_status"), null);
- await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-1" });
- assert.equal(
- await app.invoke("get_agent_run_events_status"),
- "e2e-agent-run-1",
- );
- // A second start for the same run is a no-op, not a second socket.
- await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-1" });
- assert.equal(
- await app.invoke("get_agent_run_events_status"),
- "e2e-agent-run-1",
- );
- // Opening a different run replaces the stream: the panel shows one run.
- await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-2" });
- assert.equal(
- await app.invoke("get_agent_run_events_status"),
- "e2e-agent-run-2",
- );
- await app.invoke("stop_agent_run_events");
- assert.equal(await app.invoke("get_agent_run_events_status"), null);
- // A second stop must not fail.
- await app.invoke("stop_agent_run_events");
- assert.equal(await app.invoke("get_agent_run_events_status"), null);
-
const trial = await app.invoke("get_commercial_trial_status");
assert.ok(trial && typeof trial === "object");
await app.invoke("acknowledge_trial_expiration");
diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs
index 1fbdc27a..b31b38d5 100644
--- a/e2e/tests/ui.test.mjs
+++ b/e2e/tests/ui.test.mjs
@@ -1606,109 +1606,314 @@ test("a folder with no manifest fails with the translated reason, not a raw code
});
});
-test("the agent page opens from the rail and the palette, keeps every tab live, and never shows a form it cannot honour", async () => {
- await withApp("ui-agent", async (app) => {
- await app.clickSelector(`[aria-label="${en.rail.agent}"]`);
- await app.waitForText(en.agent.unavailable.signInTitle);
+test("the agent page guides setup, then shows agents, requests, notes, take-overs and the pause live", async () => {
+ await withApp(
+ "ui-agent",
+ async (app) => {
+ await app.waitForText("No profiles yet");
+ const painted = (testId) =>
+ app.execute(
+ `const el = document.querySelector('[data-testid="' + arguments[0] + '"]');
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ const style = getComputedStyle(el);
+ return rect.width > 0 && rect.height > 0 && Number(style.opacity) === 1 && style.visibility === "visible"
+ ? el.innerText.trim()
+ : null;`,
+ [testId],
+ );
+ const waitPainted = (testId) =>
+ app.waitFor(() => painted(testId), { description: testId });
+ const absent = (testId) =>
+ app.waitFor(
+ () =>
+ app.execute(
+ `return !document.querySelector('[data-testid="' + arguments[0] + '"]');`,
+ [testId],
+ ),
+ { description: `${testId} gone` },
+ );
- // Exactly the three panels the page ships, in its order. The strip renders
- // whether or not the account can use the agent, because
- // AGENT_NOT_CONFIGURED is only learned by asking and the chrome has to be
- // on screen when the answer lands.
- const tabs = async () =>
- app.execute(
- `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim());`,
- );
- assert.deepEqual(await tabs(), [
- en.agent.tabs.run,
- en.agent.tabs.history,
- en.agent.tabs.recipes,
- ]);
+ // Signed out, the page says what is missing instead of showing controls.
+ await app.clickSelector(`[aria-label="${en.rail.agent}"]`);
+ const signedOut = await waitPainted("agent-setup-signed-out");
+ assert.match(signedOut, new RegExp(en.agent.setup.signedOutTitle));
+ assert.equal(await painted("agent-pause-switch"), null);
+ // Leaving the page unmounts it, so the next visit reads the stubs below.
+ await app.clickSelector(`[aria-label="${en.rail.profiles}"]`);
+ await absent("agent-page");
- // The explanation is PAINTED, not merely in the DOM. Content that only
- // becomes visible once an animation has run renders as an empty panel every
- // time the animation does not, which is the failure this asserts against.
- const notice = async () =>
- app.execute(`
- const el = document.querySelector('[data-slot="agent-unavailable"]');
- if (!el) return null;
- const rect = el.getBoundingClientRect();
- const style = getComputedStyle(el);
- return {
- width: rect.width,
- height: rect.height,
- opacity: Number(style.opacity),
- visibility: style.visibility,
- text: el.innerText.trim(),
+ const profile = await createUiProfile(app, "Agent UI Profile");
+ const now = Date.now();
+ try {
+ await stubCommand(app, "cloud_get_user", {
+ logged_in_at: new Date().toISOString(),
+ user: {
+ id: "ui-agent",
+ email: "agent@example.test",
+ plan: "pro",
+ planPeriod: "monthly",
+ subscriptionStatus: "active",
+ profileLimit: 50,
+ cloudProfilesUsed: 0,
+ proxyBandwidthLimitMb: 0,
+ proxyBandwidthUsedMb: 0,
+ proxyBandwidthExtraMb: 0,
+ isPrimaryDevice: true,
+ },
+ });
+ await stubCommand(app, "get_mcp_remote_status", {
+ enabled: true,
+ connected: true,
+ instanceId: "ui-agent-instance",
+ lastError: null,
+ });
+ const base = {
+ session_id: "ui-s1",
+ profile_id: null,
+ choices: [],
+ answer: null,
+ answered_at: null,
+ delivered_to: [],
+ done: null,
+ total: null,
};
- `);
- const signedOut = await notice();
- assert.ok(signedOut, "the run tab must explain itself when signed out");
- assert.ok(signedOut.width > 0 && signedOut.height > 0);
- assert.equal(signedOut.opacity, 1);
- assert.equal(signedOut.visibility, "visible");
- assert.match(signedOut.text, new RegExp(en.agent.unavailable.signInHint));
+ const question = {
+ ...base,
+ id: 9001,
+ at: now - 5000,
+ kind: "question",
+ text: "Which shipping address should I use?",
+ choices: ["Home", "Office"],
+ state: "open",
+ };
+ const help = {
+ ...base,
+ id: 9002,
+ at: now - 4000,
+ kind: "help",
+ text: "Solve the verification on the sign-in page",
+ profile_id: profile.id,
+ state: "open",
+ };
+ await stubCommand(app, "get_agent_console", {
+ sessions: [
+ {
+ session_id: "ui-s1",
+ client_name: "Claude Code",
+ client_version: "2.1.0",
+ connected_at: now - 60_000,
+ last_seen_at: now - 1000,
+ calls: 3,
+ errors: 1,
+ ended: false,
+ status: {
+ message: "Checking orders",
+ done: 2,
+ total: 5,
+ profile_id: null,
+ updated_at: now - 2000,
+ },
+ },
+ ],
+ activity: [
+ {
+ id: 9100,
+ at: now - 3000,
+ session_id: "ui-s1",
+ tool: "navigate",
+ profile_id: profile.id,
+ profile_count: null,
+ ok: true,
+ error_code: null,
+ duration_ms: 420,
+ detail: "shop.example.com",
+ },
+ {
+ id: 9101,
+ at: now - 2500,
+ session_id: "ui-s1",
+ tool: "click_locator",
+ profile_id: profile.id,
+ profile_count: null,
+ ok: false,
+ error_code: "LOCATOR_NO_MATCH",
+ duration_ms: 90,
+ detail: null,
+ },
+ ],
+ thread: [question, help],
+ holds: [
+ {
+ profile_id: profile.id,
+ since: now - 4000,
+ note: null,
+ request_id: 9002,
+ },
+ ],
+ paused: null,
+ quota: { limit: 2000, used: 240, resets_in_secs: 1200 },
+ });
+ await stubCommand(app, "answer_agent_request", {
+ ...question,
+ state: "answered",
+ answer: "Office",
+ answered_at: now,
+ });
+ await app.invoke("plugin:event|emit", {
+ event: "cloud-auth-changed",
+ payload: null,
+ });
+ await app.invoke("plugin:event|emit", {
+ event: "agent-console-cleared",
+ payload: null,
+ });
- // Never a broken form: a signed-out desktop is told what is missing, it is
- // not handed a goal field and a submit button that cannot work.
- assert.equal(
- await app.execute(
- `return document.querySelector('[data-slot="agent-run-form"]') === null;`,
- ),
- true,
- );
-
- // Every tab answers a real pointer click, and each carries its own
- // explanation rather than a blank panel.
- for (const label of [
- en.agent.tabs.history,
- en.agent.tabs.recipes,
- en.agent.tabs.run,
- ]) {
- await app.clickText(label);
- await app.waitFor(
- () =>
- app.execute(
- `return [...document.querySelectorAll('[role="tab"]')].some(
- (node) => node.textContent.trim() === arguments[0] &&
- node.getAttribute("data-state") === "active"
- );`,
- [label],
+ // Two open requests reach the rail before the page is opened.
+ await app.waitFor(
+ async () => (await painted("agent-rail-badge")) === "2",
+ { description: "the rail badge counts open requests" },
+ );
+ const waitingLabel = en.rail.agentWaiting.replace("{{count}}", "2");
+ await app.clickSelector(`[aria-label="${waitingLabel}"]`);
+ await waitPainted("agent-needs-you");
+ // The page holds the seeded snapshot now; later reads go to the backend.
+ await app.execute(`delete window.__donutStubs.get_agent_console;`);
+ await app.capture("agent-conversation");
+ // The Conversation tab carries the open-request count after its label.
+ assert.deepEqual(
+ await app.execute(
+ `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim().replace(/\\d+$/, ""));`,
),
- { description: `${label} agent tab` },
- );
- const panel = await notice();
- assert.ok(panel, `${label} must explain itself when signed out`);
- assert.ok(panel.height > 0, `${label} rendered an empty panel`);
- }
+ [
+ en.agent.tabs.conversation,
+ en.agent.tabs.profiles,
+ en.agent.tabs.activity,
+ en.agent.tabs.recipes,
+ ],
+ );
+ assert.match(await painted("agent-quota"), /1[,.\s ]?760/);
+ assert.match(await painted("agent-session-ui-s1"), /Claude Code/);
+ assert.match(await painted("agent-session-ui-s1"), /Checking orders/);
+ assert.match(
+ await painted("agent-request-9001"),
+ /Which shipping address should I use\?/,
+ );
+ assert.match(
+ await painted("agent-request-9002"),
+ /Solve the verification on the sign-in page/,
+ );
+ assert.equal(
+ await app.execute(
+ `return document.querySelectorAll('[data-testid="agent-request-9001"] [data-testid="agent-choice"]').length;`,
+ ),
+ 2,
+ );
- await dismissSurface(app);
+ // A suggested answer is one click, and the card leaves the list.
+ await app.clickText("Office", { roles: ["button"] });
+ await absent("agent-request-9001");
+ const answered = await app.execute(
+ `return (window.__donutStubbedCalls ?? []).filter((c) => c.command === "answer_agent_request").map((c) => c.payload);`,
+ );
+ assert.deepEqual(answered, [{ requestId: 9001, answer: "Office" }]);
- // The same page from the palette. A rail item nobody can reach by keyboard
- // is half a navigation.
- const modifier =
- process.platform === "darwin" ? { meta: true } : { ctrl: true };
- await app.pressShortcut({ key: "k", ...modifier });
- await app.waitFor(
- () =>
- app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`),
- { description: "command palette" },
- );
- const input = await app.session.findCss("[cmdk-input]");
- await app.session.sendKeys(input, en.shortcuts.goAgent);
- await app.clickText(en.shortcuts.goAgent, {
- exact: false,
- roles: ["option", "button", "menuitem"],
- });
- await app.waitForText(en.agent.unavailable.signInTitle);
- assert.deepEqual(await tabs(), [
- en.agent.tabs.run,
- en.agent.tabs.history,
- en.agent.tabs.recipes,
- ]);
+ // Real backend events update the open page: a note, then the pause.
+ await app.invoke("send_agent_note", {
+ text: "Use the EU shipping profile today",
+ });
+ await app.waitFor(
+ async () =>
+ ((await painted("agent-thread")) ?? "").includes(
+ "Use the EU shipping profile today",
+ ),
+ { description: "the note in the thread" },
+ );
+ await app.invoke("set_agents_paused", {
+ paused: true,
+ note: "Changing proxies",
+ });
+ assert.match(
+ await waitPainted("agent-paused-banner"),
+ /Changing proxies/,
+ );
+ await app.clickSelector('[data-testid="agent-resume"]');
+ await absent("agent-paused-banner");
+ assert.equal((await app.invoke("get_agent_console")).paused, null);
- await dismissSurface(app);
- });
+ // Profiles: the agent's profile is listed with its last error, and the
+ // person can take it over and hand it back.
+ await app.clickSelector('[data-testid="agent-tab-profiles"]');
+ const row = await waitPainted(`agent-profile-${profile.id}`);
+ assert.match(row, /Agent UI Profile/);
+ assert.match(row, /LOCATOR_NO_MATCH/);
+ await app.clickSelector(
+ `[data-testid="agent-profile-${profile.id}"] [data-testid="agent-take-over"]`,
+ );
+ await app.fillSelector(
+ '[data-testid="agent-take-over-note"]',
+ "Fixing the address",
+ );
+ await app.clickSelector('[data-testid="agent-take-over-confirm"]');
+ await app.waitFor(
+ async () =>
+ (await app.invoke("get_agent_console")).holds.some(
+ (hold) =>
+ hold.profile_id === profile.id &&
+ hold.note === "Fixing the address",
+ ),
+ { description: "the take-over reached the backend" },
+ );
+ await app.waitFor(
+ () =>
+ app.execute(
+ `return Boolean(document.querySelector('[data-testid="agent-profile-' + arguments[0] + '"] [data-testid="agent-hand-back"]'));`,
+ [profile.id],
+ ),
+ { description: "the row offers hand back" },
+ );
+ await app.clickSelector(
+ `[data-testid="agent-profile-${profile.id}"] [data-testid="agent-hand-back"]`,
+ );
+ await app.clickSelector('[data-testid="agent-hand-back-confirm"]');
+ await app.waitFor(
+ async () =>
+ (await app.invoke("get_agent_console")).holds.length === 0,
+ { description: "the hand-back reached the backend" },
+ );
+
+ await app.clickSelector('[data-testid="agent-tab-activity"]');
+ const activity = await waitPainted("agent-activity-list");
+ assert.match(activity, /shop\.example\.com/);
+ assert.match(activity, /LOCATOR_NO_MATCH/);
+ await app.capture("agent-page");
+ await dismissSurface(app);
+
+ // The palette reaches the same page.
+ const modifier =
+ process.platform === "darwin" ? { meta: true } : { ctrl: true };
+ await app.pressShortcut({ key: "k", ...modifier });
+ await app.waitFor(
+ () =>
+ app.execute(
+ `return Boolean(document.querySelector("[cmdk-input]"));`,
+ ),
+ { description: "command palette" },
+ );
+ const input = await app.session.findCss("[cmdk-input]");
+ await app.session.sendKeys(input, en.shortcuts.goAgent);
+ await app.clickText(en.shortcuts.goAgent, {
+ exact: false,
+ roles: ["option", "button", "menuitem"],
+ });
+ await waitPainted("agent-page");
+ await dismissSurface(app);
+ } finally {
+ await restoreStubs(app);
+ }
+ },
+ { settings: { paid_welcome_seen_for: ["ui-agent"] } },
+ );
});
async function createUiProfile(app, name) {
@@ -3088,3 +3293,120 @@ test("an account that can use remote MCP is offered the move once, and it runs c
}
});
});
+
+test("Pro users can edit and save launch arguments in each profile", async () => {
+ await withApp(
+ "ui-launch-args",
+ async (app) => {
+ const profile = await createUiProfile(app, "Profile Launch Arguments");
+ await app.waitForText(profile.name);
+ const openEditor = async () => {
+ await app.clickSelector(
+ `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`,
+ );
+ await app.clickSelector(
+ '[data-slot="profile-info-section"][data-section="fingerprint"]',
+ );
+ await app.waitForText(en.fingerprint.launchArgs);
+ };
+ await openEditor();
+ assert.equal(
+ await app.execute(
+ 'return document.querySelector("#wayfern-launch-args").disabled;',
+ ),
+ true,
+ );
+ await app.clickSelector('[data-slot="profile-info-close"]');
+ try {
+ await stubCommand(app, "cloud_get_user", {
+ logged_in_at: "2020-01-01T00:00:00Z",
+ user: {
+ id: "launch-args-pro",
+ email: "launch-args@example.test",
+ plan: "pro",
+ planPeriod: "monthly",
+ subscriptionStatus: "active",
+ profileLimit: 50,
+ isPrimaryDevice: true,
+ },
+ });
+ await app.invoke("plugin:event|emit", {
+ event: "cloud-auth-changed",
+ payload: null,
+ });
+ await openEditor();
+ await app.waitFor(
+ () =>
+ app.execute(
+ 'return !document.querySelector("#wayfern-launch-args").disabled;',
+ ),
+ { description: "Pro launch arguments editor" },
+ );
+ const input = "--disable-gpu\n--renderer-process-limit=4";
+ await app.fillSelector("#wayfern-launch-args", input);
+ assert.equal(
+ await app.execute(
+ 'return document.querySelector("#wayfern-launch-args").value;',
+ ),
+ input,
+ );
+ await app.capture("launch-arguments-pro");
+ await app.clickText(en.common.buttons.save, { exact: true });
+ await app.waitFor(
+ async () =>
+ (await app.invoke("list_browser_profiles"))[0].wayfern_config
+ .launch_args?.length === 2,
+ { description: "saved launch arguments" },
+ );
+ await openEditor();
+ assert.equal(
+ await app.execute(
+ 'return document.querySelector("#wayfern-launch-args").value;',
+ ),
+ input,
+ );
+ await app.fillSelector(
+ "#wayfern-launch-args",
+ "--user-data-dir=/tmp/other",
+ );
+ await app.clickText(en.common.buttons.save, { exact: true });
+ await app.waitForText(
+ en.backendErrors.wayfernLaunchArgReserved.replace(
+ "{{argument}}",
+ "--user-data-dir",
+ ),
+ );
+ assert.deepEqual(
+ (await app.invoke("list_browser_profiles"))[0].wayfern_config
+ .launch_args,
+ input.split("\n"),
+ );
+ await app.clickSelector('[data-slot="profile-info-close"]');
+ await restoreStubs(app);
+ await app.invoke("plugin:event|emit", {
+ event: "cloud-auth-changed",
+ payload: null,
+ });
+ await openEditor();
+ await app.waitFor(
+ () =>
+ app.execute(
+ 'return document.querySelector("#wayfern-launch-args").disabled;',
+ ),
+ { description: "launch arguments locked after sign out" },
+ );
+ await app.clickText(en.common.buttons.clear, { exact: true });
+ await app.clickText(en.common.buttons.save, { exact: true });
+ await app.waitFor(
+ async () =>
+ !(await app.invoke("list_browser_profiles"))[0].wayfern_config
+ .launch_args?.length,
+ { description: "cleared launch arguments" },
+ );
+ } finally {
+ await restoreStubs(app);
+ }
+ },
+ { extraEnv: { WAYFERN_TEST_TOKEN: "e2e-local-launch-args" } },
+ );
+});
diff --git a/flake.lock b/flake.lock
index 083c58f9..36fda4e8 100644
--- a/flake.lock
+++ b/flake.lock
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1779560665,
- "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
+ "lastModified": 1790822859,
+ "narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
+ "rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index e9998541..71cc6057 100644
--- a/flake.nix
+++ b/flake.nix
@@ -7,16 +7,19 @@
};
outputs = { self, nixpkgs, flake-utils, ... }:
- flake-utils.lib.eachDefaultSystem (system:
+ # Not eachDefaultSystem: nixpkgs 26.11 dropped x86_64-darwin, and evaluating
+ # it there aborts `nix flake show --all-systems` for every system.
+ flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ] (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
lib = pkgs.lib;
+ isLinux = pkgs.stdenv.hostPlatform.isLinux;
- # The version in .node-version; 22 only where 24 is not packaged.
- nodejs = pkgs.nodejs_24 or pkgs.nodejs_22;
+ # The version in .node-version; 24 only where 26 is not packaged.
+ nodejs = pkgs.nodejs_26 or pkgs.nodejs_24;
rustPackages = with pkgs; [
cargo
@@ -26,7 +29,9 @@
rustfmt
];
- commonLibs = with pkgs; [
+ # The Linux GTK/WebKit stack. On macOS, Tauri uses the system WebKit
+ # through the Apple SDK that stdenv already provides.
+ commonLibs = lib.optionals isLinux (with pkgs; [
webkitgtk_4_1
libsoup_3
glib
@@ -70,11 +75,11 @@
gmp
zlib
stdenv.cc.cc.lib
- ];
+ ]);
runtimeLibPath = lib.makeLibraryPath commonLibs;
nixLd = pkgs.stdenv.cc.bintools.dynamicLinker;
- pkgConfigLibs = [
+ pkgConfigLibs = [ pkgs.openssl pkgs.zlib ] ++ lib.optionals isLinux [
pkgs.at-spi2-atk
pkgs.at-spi2-core
pkgs.cairo
@@ -85,11 +90,9 @@
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
- pkgs.openssl
pkgs.pango
pkgs.harfbuzz
pkgs.webkitgtk_4_1
- pkgs.zlib
];
# The .pc files these name in Requires have to resolve too: gdk-3.0
# requires zlib, and a list of direct dependencies alone left it out,
@@ -189,6 +192,17 @@
'';
};
+ linuxLibraryEnv = lib.optionalString isLinux ''
+ export NIX_LD="${nixLd}"
+ export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
+ export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
+ export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
+ export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:''${XDG_DATA_DIRS:-}"
+ '';
+ pkgConfigModules =
+ (lib.optionals isLinux [ "gdk-3.0" "webkit2gtk-4.1" "javascriptcoregtk-4.1" "libsoup-3.0" "ayatana-appindicator3-0.1" ])
+ ++ [ "openssl" ];
+
mkApp = name: text:
let
app = pkgs.writeShellApplication {
@@ -201,7 +215,7 @@
gnugrep
gnused
curl
- gcc
+ stdenv.cc
pkg-config
openssl
cargo
@@ -214,10 +228,7 @@
];
text = ''
export NODE_ENV=development
- export NIX_LD="${nixLd}"
- export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
- export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
- export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
+ ${linuxLibraryEnv}
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
${text}
@@ -254,13 +265,9 @@
shellHook = ''
export NODE_ENV=development
- export NIX_LD="${nixLd}"
- export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
- export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
- export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
+ ${linuxLibraryEnv}
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
- export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:''${XDG_DATA_DIRS:-}"
echo "Donut Browser dev shell ready."
echo "Quick start:"
@@ -283,7 +290,8 @@
# Every library the Rust build links through pkg-config, with the
# packages their .pc files require. A gap fails here in seconds
# instead of minutes into `nix run .#build`.
- for module in gdk-3.0 webkit2gtk-4.1 javascriptcoregtk-4.1 libsoup-3.0 ayatana-appindicator3-0.1 openssl; do
+ modules=(${lib.escapeShellArgs pkgConfigModules})
+ for module in "''${modules[@]}"; do
pkg-config --cflags --libs "$module" > /dev/null
echo "$module: $(pkg-config --modversion "$module")"
done
diff --git a/osv-scanner.toml b/osv-scanner.toml
index d148a3ee..05c0177e 100644
--- a/osv-scanner.toml
+++ b/osv-scanner.toml
@@ -30,29 +30,3 @@ ignoreUntil = 2026-12-31
id = "RUSTSEC-2024-0370"
reason = "Unmaintained crate, no fix published; transitive build dependency on Linux targets."
ignoreUntil = 2026-12-31
-
-# unic-* <- urlpattern <- tauri-utils. One upstream family, five advisories.
-[[IgnoredVulns]]
-id = "RUSTSEC-2025-0081"
-reason = "Unmaintained unic-* family, no fix published; transitive through tauri-utils -> urlpattern."
-ignoreUntil = 2026-12-31
-
-[[IgnoredVulns]]
-id = "RUSTSEC-2025-0075"
-reason = "Unmaintained unic-* family, no fix published; transitive through tauri-utils -> urlpattern."
-ignoreUntil = 2026-12-31
-
-[[IgnoredVulns]]
-id = "RUSTSEC-2025-0080"
-reason = "Unmaintained unic-* family, no fix published; transitive through tauri-utils -> urlpattern."
-ignoreUntil = 2026-12-31
-
-[[IgnoredVulns]]
-id = "RUSTSEC-2025-0100"
-reason = "Unmaintained unic-* family, no fix published; transitive through tauri-utils -> urlpattern."
-ignoreUntil = 2026-12-31
-
-[[IgnoredVulns]]
-id = "RUSTSEC-2025-0098"
-reason = "Unmaintained unic-* family, no fix published; transitive through tauri-utils -> urlpattern."
-ignoreUntil = 2026-12-31
diff --git a/package.json b/package.json
index 965ea4cd..2445e8f5 100644
--- a/package.json
+++ b/package.json
@@ -10,13 +10,14 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
- "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
+ "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:tips": "node --test src/lib/tips.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs",
"test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs",
- "test:agent": "node --test src/lib/agent.test.mjs",
+ "test:recipes": "node --test src/lib/recipes.test.mjs",
+ "test:agent-console": "node --test src/lib/agent-console.test.mjs",
"test:backend-errors": "node --test src/lib/backend-errors.test.mjs",
"test:i18n-parity": "node --test src/lib/i18n-parity.test.mjs",
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
@@ -74,10 +75,10 @@
"@tanstack/react-table": "^9.2.4",
"@tanstack/react-virtual": "^3.14.13",
"@tanstack/table-core": "9.2.4",
- "@tauri-apps/api": "~2.12.0",
- "@tauri-apps/plugin-clipboard-manager": "~2.4.0",
- "@tauri-apps/plugin-deep-link": "~2.6.0",
- "@tauri-apps/plugin-dialog": "~2.8.0",
+ "@tauri-apps/api": "~2.12.1",
+ "@tauri-apps/plugin-clipboard-manager": "~2.4.1",
+ "@tauri-apps/plugin-deep-link": "~2.6.1",
+ "@tauri-apps/plugin-dialog": "~2.8.1",
"@tauri-apps/plugin-fs": "~2.6.0",
"@tauri-apps/plugin-log": "~2.10.0",
"@tauri-apps/plugin-opener": "~2.7.0",
@@ -90,9 +91,9 @@
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^26.4.2",
- "lucide-react": "^1.49.0",
- "motion": "^13.4.6",
- "next": "^16.3.7",
+ "lucide-react": "^1.50.0",
+ "motion": "^14.0.0",
+ "next": "^16.3.8",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.6.7",
@@ -106,12 +107,12 @@
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
- "@biomejs/biome": "2.5.14",
+ "@biomejs/biome": "2.5.15",
"@tailwindcss/postcss": "^4.3.3",
- "@tauri-apps/cli": "~2.12.0",
+ "@tauri-apps/cli": "~2.12.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
- "@types/node": "^26.6.3",
+ "@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"husky": "^9.1.7",
@@ -122,7 +123,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~7.0.2"
},
- "packageManager": "pnpm@12.6.0",
+ "packageManager": "pnpm@12.8.1",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
diff --git a/packaging/flatpak/com.donutbrowser.DonutBrowser.yml b/packaging/flatpak/com.donutbrowser.DonutBrowser.yml
index bd334b3d..5c00f01d 100644
--- a/packaging/flatpak/com.donutbrowser.DonutBrowser.yml
+++ b/packaging/flatpak/com.donutbrowser.DonutBrowser.yml
@@ -12,7 +12,7 @@
# build-dir packaging/flatpak/com.donutbrowser.DonutBrowser.yml
id: com.donutbrowser.DonutBrowser
runtime: org.gnome.Platform
-runtime-version: "50"
+runtime-version: "51"
sdk: org.gnome.Sdk
command: donutbrowser
rename-desktop-file: Donut.desktop
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce32f6be..77f5d1ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.6.0
- version: 12.6.0
+ specifier: 12.8.1
+ version: 12.8.1
packages:
- '@pnpm/exe.android-arm64@12.6.0':
- resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==}
+ '@pnpm/exe.android-arm64@12.8.1':
+ resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==}
cpu: [arm64]
os: [android]
- '@pnpm/exe.android-x64@12.6.0':
- resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==}
+ '@pnpm/exe.android-x64@12.8.1':
+ resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==}
cpu: [x64]
os: [android]
- '@pnpm/exe.darwin-arm64@12.6.0':
- resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==}
+ '@pnpm/exe.darwin-arm64@12.8.1':
+ resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.6.0':
- resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==}
+ '@pnpm/exe.darwin-x64@12.8.1':
+ resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.freebsd-x64@12.6.0':
- resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==}
+ '@pnpm/exe.freebsd-x64@12.8.1':
+ resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==}
cpu: [x64]
os: [freebsd]
- '@pnpm/exe.linux-arm64-musl@12.6.0':
- resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==}
+ '@pnpm/exe.linux-arm64-musl@12.8.1':
+ resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.6.0':
- resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==}
+ '@pnpm/exe.linux-arm64@12.8.1':
+ resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-ppc64@12.6.0':
- resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==}
+ '@pnpm/exe.linux-ppc64@12.8.1':
+ resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-riscv64@12.6.0':
- resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==}
+ '@pnpm/exe.linux-riscv64@12.8.1':
+ resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-s390x@12.6.0':
- resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==}
+ '@pnpm/exe.linux-s390x@12.8.1':
+ resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.6.0':
- resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==}
+ '@pnpm/exe.linux-x64-musl@12.8.1':
+ resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.6.0':
- resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==}
+ '@pnpm/exe.linux-x64@12.8.1':
+ resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.6.0':
- resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==}
+ '@pnpm/exe.win32-arm64@12.8.1':
+ resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.6.0':
- resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==}
+ '@pnpm/exe.win32-x64@12.8.1':
+ resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==}
cpu: [x64]
os: [win32]
- pnpm@12.6.0:
- resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==}
+ pnpm@12.8.1:
+ resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.android-arm64@12.6.0':
+ '@pnpm/exe.android-arm64@12.8.1':
optional: true
- '@pnpm/exe.android-x64@12.6.0':
+ '@pnpm/exe.android-x64@12.8.1':
optional: true
- '@pnpm/exe.darwin-arm64@12.6.0':
+ '@pnpm/exe.darwin-arm64@12.8.1':
optional: true
- '@pnpm/exe.darwin-x64@12.6.0':
+ '@pnpm/exe.darwin-x64@12.8.1':
optional: true
- '@pnpm/exe.freebsd-x64@12.6.0':
+ '@pnpm/exe.freebsd-x64@12.8.1':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.6.0':
+ '@pnpm/exe.linux-arm64-musl@12.8.1':
optional: true
- '@pnpm/exe.linux-arm64@12.6.0':
+ '@pnpm/exe.linux-arm64@12.8.1':
optional: true
- '@pnpm/exe.linux-ppc64@12.6.0':
+ '@pnpm/exe.linux-ppc64@12.8.1':
optional: true
- '@pnpm/exe.linux-riscv64@12.6.0':
+ '@pnpm/exe.linux-riscv64@12.8.1':
optional: true
- '@pnpm/exe.linux-s390x@12.6.0':
+ '@pnpm/exe.linux-s390x@12.8.1':
optional: true
- '@pnpm/exe.linux-x64-musl@12.6.0':
+ '@pnpm/exe.linux-x64-musl@12.8.1':
optional: true
- '@pnpm/exe.linux-x64@12.6.0':
+ '@pnpm/exe.linux-x64@12.8.1':
optional: true
- '@pnpm/exe.win32-arm64@12.6.0':
+ '@pnpm/exe.win32-arm64@12.8.1':
optional: true
- '@pnpm/exe.win32-x64@12.6.0':
+ '@pnpm/exe.win32-x64@12.8.1':
optional: true
- pnpm@12.6.0:
+ pnpm@12.8.1:
optionalDependencies:
- '@pnpm/exe.android-arm64': 12.6.0
- '@pnpm/exe.android-x64': 12.6.0
- '@pnpm/exe.darwin-arm64': 12.6.0
- '@pnpm/exe.darwin-x64': 12.6.0
- '@pnpm/exe.freebsd-x64': 12.6.0
- '@pnpm/exe.linux-arm64': 12.6.0
- '@pnpm/exe.linux-arm64-musl': 12.6.0
- '@pnpm/exe.linux-ppc64': 12.6.0
- '@pnpm/exe.linux-riscv64': 12.6.0
- '@pnpm/exe.linux-s390x': 12.6.0
- '@pnpm/exe.linux-x64': 12.6.0
- '@pnpm/exe.linux-x64-musl': 12.6.0
- '@pnpm/exe.win32-arm64': 12.6.0
- '@pnpm/exe.win32-x64': 12.6.0
+ '@pnpm/exe.android-arm64': 12.8.1
+ '@pnpm/exe.android-x64': 12.8.1
+ '@pnpm/exe.darwin-arm64': 12.8.1
+ '@pnpm/exe.darwin-x64': 12.8.1
+ '@pnpm/exe.freebsd-x64': 12.8.1
+ '@pnpm/exe.linux-arm64': 12.8.1
+ '@pnpm/exe.linux-arm64-musl': 12.8.1
+ '@pnpm/exe.linux-ppc64': 12.8.1
+ '@pnpm/exe.linux-riscv64': 12.8.1
+ '@pnpm/exe.linux-s390x': 12.8.1
+ '@pnpm/exe.linux-x64': 12.8.1
+ '@pnpm/exe.linux-x64-musl': 12.8.1
+ '@pnpm/exe.win32-arm64': 12.8.1
+ '@pnpm/exe.win32-x64': 12.8.1
---
lockfileVersion: '9.0'
@@ -163,25 +163,8 @@ settings:
excludeLinksFromLockfile: false
overrides:
- '@tauri-apps/api': ~2.11.1
- picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
- path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
- postcss@<8.5.18: '>=8.5.18'
- fast-xml-parser@<5.7.0: '>=5.7.2'
- fast-uri@<3.1.5: '>=3.1.5 <4'
- fast-xml-builder@<1.2.0: '>=1.2.0'
- qs@<6.16.0: '>=6.16.0'
- js-cookie@<3.0.7: '>=3.0.7'
- nanoid@<3.3.17: '>=3.3.17 <4'
- fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
- multer@>=2.0.0 <2.3.0: '>=2.3.0'
- form-data@>=4.0.0 <4.0.6: '>=4.0.6'
- js-yaml@<3.15.2: '>=3.15.2 <4'
- js-yaml@>=4.0.0 <4.3.2: '>=4.3.2 <5'
- browserslist@<4.28.7: '>=4.28.7'
- '@babel/core@<7.29.6': '>=7.29.6 <8'
+ '@tauri-apps/api': ~2.12.1
brace-expansion@<5.0.12: 5.0.12
- sharp@<0.35.0: '>=0.35.0 <0.36'
patchedDependencies:
brace-expansion@5.0.12: 1879756c1a76d4da4ca83c9b052e789e955e6b3a04262a14059998ccc87f0aee
@@ -239,17 +222,17 @@ importers:
specifier: 9.2.4
version: 9.2.4
'@tauri-apps/api':
- specifier: ~2.11.1
- version: 2.11.1
+ specifier: ~2.12.1
+ version: 2.12.1
'@tauri-apps/plugin-clipboard-manager':
- specifier: ~2.4.0
- version: 2.4.0
+ specifier: ~2.4.1
+ version: 2.4.1
'@tauri-apps/plugin-deep-link':
- specifier: ~2.6.0
- version: 2.6.0
+ specifier: ~2.6.1
+ version: 2.6.1
'@tauri-apps/plugin-dialog':
- specifier: ~2.8.0
- version: 2.8.0
+ specifier: ~2.8.1
+ version: 2.8.1
'@tauri-apps/plugin-fs':
specifier: ~2.6.0
version: 2.6.0
@@ -287,20 +270,20 @@ importers:
specifier: ^26.4.2
version: 26.4.2(typescript@7.0.2)
lucide-react:
- specifier: ^1.49.0
- version: 1.49.0(@types/react@19.3.0)(react@19.3.0)
+ specifier: ^1.50.0
+ version: 1.50.0(@types/react@19.3.0)(react@19.3.0)
motion:
- specifier: ^13.4.6
- version: 13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ specifier: ^14.0.0
+ version: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next:
- specifier: ^16.3.7
- version: 16.3.7(@babel/core@8.0.1)(@types/node@26.6.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ specifier: ^16.3.8
+ version: 16.3.8(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
onborda:
specifier: ^1.2.5
- version: 1.2.5(@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.3.7(@babel/core@8.0.1)(@types/node@26.6.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 1.2.5(@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.3.8(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
radix-ui:
specifier: ^1.6.7
version: 1.6.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -330,14 +313,14 @@ importers:
version: 2.3.0
devDependencies:
'@biomejs/biome':
- specifier: 2.5.14
- version: 2.5.14
+ specifier: 2.5.15
+ version: 2.5.15
'@tailwindcss/postcss':
specifier: ^4.3.3
version: 4.3.3
'@tauri-apps/cli':
- specifier: ~2.12.0
- version: 2.12.0
+ specifier: ~2.12.1
+ version: 2.12.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
@@ -345,8 +328,8 @@ importers:
specifier: ^4.2.1
version: 4.2.1
'@types/node':
- specifier: ^26.6.3
- version: 26.6.3
+ specifier: ^26.6.4
+ version: 26.6.4
'@types/react':
specifier: ^19.3.0
version: 19.3.0
@@ -378,23 +361,23 @@ importers:
donut-sync:
dependencies:
'@aws-sdk/client-s3':
- specifier: ^3.1143.0
- version: 3.1143.0
+ specifier: ^3.1145.0
+ version: 3.1145.0
'@aws-sdk/s3-request-presigner':
- specifier: ^3.1143.0
- version: 3.1143.0
+ specifier: ^3.1145.0
+ version: 3.1145.0
'@nestjs/common':
- specifier: ^12.1.1
- version: 12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
+ specifier: ^12.1.2
+ version: 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/config':
specifier: ^12.0.1
- version: 12.0.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2)
+ version: 12.0.1(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2)
'@nestjs/core':
- specifier: ^12.1.1
- version: 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ specifier: ^12.1.2
+ version: 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
- specifier: ^12.1.1
- version: 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(supports-color@8.1.1)
+ specifier: ^12.1.2
+ version: 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(supports-color@8.1.1)
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@@ -407,13 +390,13 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^12.0.8
- version: 12.0.8(@types/node@26.6.3)(fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)))(ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)))(tsconfig-paths-webpack-plugin@4.2.0)(webpack-node-externals@3.0.0)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))
+ version: 12.0.8(@types/node@26.6.4)(fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)))(ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)))(tsconfig-paths-webpack-plugin@4.2.0)(webpack-node-externals@3.0.0)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))
'@nestjs/schematics':
specifier: ^12.0.6
version: 12.0.6(chokidar@5.0.0)(typescript@6.0.3)
'@nestjs/testing':
- specifier: ^12.1.1
- version: 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(@nestjs/platform-express@12.1.1)
+ specifier: ^12.1.2
+ version: 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(@nestjs/platform-express@12.1.2)
'@types/express':
specifier: ^5.0.6
version: 5.0.6
@@ -424,29 +407,29 @@ importers:
specifier: ^9.0.10
version: 9.0.10
'@types/node':
- specifier: ^26.6.3
- version: 26.6.3
+ specifier: ^26.6.4
+ version: 26.6.4
'@types/supertest':
specifier: ^7.2.1
version: 7.2.1
jest:
specifier: ^30.5.2
- version: 30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ version: 30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
source-map-support:
specifier: ^0.5.21
version: 0.5.21
supertest:
- specifier: ^7.3.0
- version: 7.3.0(supports-color@8.1.1)
+ specifier: ^7.3.1
+ version: 7.3.1(supports-color@8.1.1)
ts-jest:
specifier: ^29.4.14
- version: 29.4.14(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.5.2(supports-color@8.1.1))(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3)))(typescript@6.0.3)
+ version: 29.4.14(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.5.2(supports-color@8.1.1))(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3)))(typescript@6.0.3)
ts-loader:
specifier: ^9.6.2
- version: 9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))
+ version: 9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))
ts-node:
specifier: ^10.9.2
- version: 10.9.2(@types/node@26.6.3)(typescript@6.0.3)
+ version: 10.9.2(@types/node@26.6.4)(typescript@6.0.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@@ -482,8 +465,8 @@ packages:
resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-s3@3.1143.0':
- resolution: {integrity: sha512-YVcwL17cUDru7OJFMoGB6aJCg7TmMiuAUjKfswtSm+t8NhO5BLLFTUhjnGWrhdw7ZgYyU2Y8u19t8a7yu0EhEg==}
+ '@aws-sdk/client-s3@3.1145.0':
+ resolution: {integrity: sha512-MPKAQdx8qCZW1/ChDPILNehtAn/BMP4GNTnaH/xhGCWg3v50ADVq1K2WzxIrFfeANdWUyjwiY0CXDBhclsNWgQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.1':
@@ -530,8 +513,8 @@ packages:
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/s3-request-presigner@3.1143.0':
- resolution: {integrity: sha512-E+NP73AhU0NXXNAYv6JTdN9Jdm9yUS0WheSIh/QOhpM1Dd4JeTsamxbiLYvboyhdnPQAkqFYITHImaS1Bhqafg==}
+ '@aws-sdk/s3-request-presigner@3.1145.0':
+ resolution: {integrity: sha512-ewlTaWzlzmpMDU3C3rqYypaOhyvn7JAyyscJzbTbJa9XHPDGt+e8CYN8FH8GMGClGhSXq9RPAvD19O4fihUTXQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.47':
@@ -610,7 +593,7 @@ packages:
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0
'@babel/helper-plugin-utils@7.29.7':
resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
@@ -661,93 +644,93 @@ packages:
'@babel/plugin-syntax-async-generators@7.8.4':
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-bigint@7.8.3':
resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-class-properties@7.12.13':
resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-class-static-block@7.14.5':
resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-attributes@7.29.7':
resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-meta@7.10.4':
resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-json-strings@7.8.3':
resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-jsx@7.29.7':
resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-logical-assignment-operators@7.10.4':
resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3':
resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-numeric-separator@7.10.4':
resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-object-rest-spread@7.8.3':
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-optional-catch-binding@7.8.3':
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-optional-chaining@7.8.3':
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-private-property-in-object@7.14.5':
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-top-level-await@7.14.5':
resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.29.7':
resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0-0
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
@@ -780,59 +763,59 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
- '@biomejs/biome@2.5.14':
- resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==}
+ '@biomejs/biome@2.5.15':
+ resolution: {integrity: sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==}
engines: {node: '>=14.21.3'}
hasBin: true
- '@biomejs/cli-darwin-arm64@2.5.14':
- resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==}
+ '@biomejs/cli-darwin-arm64@2.5.15':
+ resolution: {integrity: sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
- '@biomejs/cli-darwin-x64@2.5.14':
- resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==}
+ '@biomejs/cli-darwin-x64@2.5.15':
+ resolution: {integrity: sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
- '@biomejs/cli-linux-arm64-musl@2.5.14':
- resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==}
+ '@biomejs/cli-linux-arm64-musl@2.5.15':
+ resolution: {integrity: sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@biomejs/cli-linux-arm64@2.5.14':
- resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==}
+ '@biomejs/cli-linux-arm64@2.5.15':
+ resolution: {integrity: sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@biomejs/cli-linux-x64-musl@2.5.14':
- resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==}
+ '@biomejs/cli-linux-x64-musl@2.5.15':
+ resolution: {integrity: sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@biomejs/cli-linux-x64@2.5.14':
- resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==}
+ '@biomejs/cli-linux-x64@2.5.15':
+ resolution: {integrity: sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@biomejs/cli-win32-arm64@2.5.14':
- resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==}
+ '@biomejs/cli-win32-arm64@2.5.15':
+ resolution: {integrity: sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
- '@biomejs/cli-win32-x64@2.5.14':
- resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==}
+ '@biomejs/cli-win32-x64@2.5.15':
+ resolution: {integrity: sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -1332,8 +1315,8 @@ packages:
webpack-node-externals:
optional: true
- '@nestjs/common@12.1.1':
- resolution: {integrity: sha512-foR+9SDz53vd4WXvN5UEXbcU0i/i9mf/zXqbAWlEDQlxAooS4kZoY5X/Zr2DsUJZaLPOk3kFQdu6EhbWrhAoDQ==}
+ '@nestjs/common@12.1.2':
+ resolution: {integrity: sha512-e2tvLcEaG18sJy5IXrdSLv0ZBpR5djWxypCLkLuMAEFCvVPYL1lEIhvAMBkarlg+ZA8tbZN40J0tFurUl9NpdA==}
peerDependencies:
class-transformer: '>=0.4.1'
class-validator: '>=0.13.2'
@@ -1351,8 +1334,8 @@ packages:
'@nestjs/common': ^11.0.0 || ^12.0.0
rxjs: ^7.1.0
- '@nestjs/core@12.1.1':
- resolution: {integrity: sha512-wI6zlO7sfoL4532/XXVkdPSiiXPAUsFdUkK42bTxVKoaEj60x8y8k95HtxOy5q/tUcXcB2Q4HIFKwVjgMamHeg==}
+ '@nestjs/core@12.1.2':
+ resolution: {integrity: sha512-ieNRDv6P6dLsF/wMz9IUjwnyQbj7/Oluq1FXe3Xhje7TFSKPprLc83QniA2I6tjkr7YVDosdL2Dj7CVv9kU8og==}
engines: {node: '>= 20'}
peerDependencies:
'@nestjs/common': ^12.0.0
@@ -1369,8 +1352,8 @@ packages:
'@nestjs/websockets':
optional: true
- '@nestjs/platform-express@12.1.1':
- resolution: {integrity: sha512-8AhH8CyGK2OF8liNkUkX/l/zEX83vXnhYxrZduMDeZ2VOTEdz0DDzslQK06k1qJEIPzyBbpafYxEFOiBVYZrdg==}
+ '@nestjs/platform-express@12.1.2':
+ resolution: {integrity: sha512-//ftPv5+UONizm5zcme/AP91EFo1EsqFFyC2I3HC2w8swYVXadHKW1L3A2mgUuOhM1sdKkd2gzjQmL9504x85Q==}
peerDependencies:
'@nestjs/common': ^12.0.0
'@nestjs/core': ^12.0.0
@@ -1385,8 +1368,8 @@ packages:
prettier:
optional: true
- '@nestjs/testing@12.1.1':
- resolution: {integrity: sha512-kMCTifsIlvfdCs/4F495HY0tjmwHu0d5JoXpr9BsT8CJ1NScfbrx24mlkevFNpq26jTQKR5e82hOfqikad1gSg==}
+ '@nestjs/testing@12.1.2':
+ resolution: {integrity: sha512-JgetYeGHGK6iUEztXyiQLOfYLWqUgAyexLMzhuAMasgLiDgYShh/PoRESE9tzpRZo4eWKme3uSZ9DFR6Myuojw==}
peerDependencies:
'@nestjs/common': ^12.0.0
'@nestjs/core': ^12.0.0
@@ -1398,57 +1381,57 @@ packages:
'@nestjs/platform-express':
optional: true
- '@next/env@16.3.7':
- resolution: {integrity: sha512-/HuhBN1CorqNTmewTIh81yXOQri6B8Ye/c9D7+830XqwVaf8qMnJNrgNxDn6dLtihq8YzBbI6NSAuMKvpr+DvQ==}
+ '@next/env@16.3.8':
+ resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
- '@next/swc-darwin-arm64@16.3.7':
- resolution: {integrity: sha512-MDAd3woxfJOVFtfG2VAuaz5zyyVZTMVUPJAknotCNgeEPrPlRBlcV9YSd8S620P6TiSc2MWpfwO/UAHl9EWRug==}
+ '@next/swc-darwin-arm64@16.3.8':
+ resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.3.7':
- resolution: {integrity: sha512-Usd86QilBWl2G7JxfWskV9NLAxacVRrDDQ3WQIoPKRB0MXiVuVSdyu3wt5EA8SyjQpaVk/7VOzSkRB4LgJltyA==}
+ '@next/swc-darwin-x64@16.3.8':
+ resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.3.7':
- resolution: {integrity: sha512-pVauSs1WomgtBgfJj/Q+846nBix4CdohLUIDafyJGdAvNw5kOusLawoX+rpc2P4U3Kd7tLwqFXsnHeSkLZrqyw==}
+ '@next/swc-linux-arm64-gnu@16.3.8':
+ resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.3.7':
- resolution: {integrity: sha512-vY+iamd6cOfk29bGTgxeS/OXlrlfOfyFgyd34cibC7e25bXRMxuqNUfs2Z9Fxb/VHpV5dopffMSWnCeR2tc8rA==}
+ '@next/swc-linux-arm64-musl@16.3.8':
+ resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.3.7':
- resolution: {integrity: sha512-NWx0LRZ9IO9rTDXLc+Hi/bNcTKblT86du4OLMRm5Z62C2T5/+mNEhK2XeQxwoSmVFvU2rjMSjJMK4ttZAk88+w==}
+ '@next/swc-linux-x64-gnu@16.3.8':
+ resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.3.7':
- resolution: {integrity: sha512-v5Dk/iMB4JyQZwQ+UWOZGGAqA4HjkBewKwaCkXi5DqL1Dy1TjXpRmAh8N7VOuID0hLmouICSXozJrMBBG1Xeyw==}
+ '@next/swc-linux-x64-musl@16.3.8':
+ resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.3.7':
- resolution: {integrity: sha512-G4BkB7AhfKJnaoIpIkoA66l5pXPGJXD3EF1EsuYj/sgM4rHA41dsa3CIIqjR4FQFZEjU+zGQRbIEPYZIOi5EoA==}
+ '@next/swc-win32-arm64-msvc@16.3.8':
+ resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.3.7':
- resolution: {integrity: sha512-DuvRhf50tGU7leT9Ow0cs/9lo81X5lHdNoxSRk4ckSk2Ihn2dsM+BEe5j7ANpEFhD25p//SmV8ajdRtuaBIg3g==}
+ '@next/swc-win32-x64-msvc@16.3.8':
+ resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2417,93 +2400,93 @@ packages:
'@tanstack/virtual-core@3.17.11':
resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==}
- '@tauri-apps/api@2.11.1':
- resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
+ '@tauri-apps/api@2.12.1':
+ resolution: {integrity: sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==}
- '@tauri-apps/cli-darwin-arm64@2.12.0':
- resolution: {integrity: sha512-v5J+jISihLGtih1+3yWagbsL+5dbLZORu4vaSI0vhY0Heb+MAW+T/1s1RyKxrQrwEPGBG5kqwfDDTnSbmVkuqA==}
+ '@tauri-apps/cli-darwin-arm64@2.12.1':
+ resolution: {integrity: sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@tauri-apps/cli-darwin-x64@2.12.0':
- resolution: {integrity: sha512-g27I07pfW/dMS8vcNqB9lamgKF0TqLlcy8e7t9qDGsB1iaeyjJWb2TgBF4tM9ZwLqPAZUXbsz28oXljO12WC4g==}
+ '@tauri-apps/cli-darwin-x64@2.12.1':
+ resolution: {integrity: sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@tauri-apps/cli-linux-arm-gnueabihf@2.12.0':
- resolution: {integrity: sha512-gXyQ710fNc99YuxFxNyeX9PLSrIpbtgBBj7GQo9tSeRT6fIK40q3/gm7X5jqZo7mWLR2m0R/2ALd3vAWo0Z32Q==}
+ '@tauri-apps/cli-linux-arm-gnueabihf@2.12.1':
+ resolution: {integrity: sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
- '@tauri-apps/cli-linux-arm64-gnu@2.12.0':
- resolution: {integrity: sha512-H9cyXuq9WgxoHh2NZ+R+oN+W78x5UwaBDEp0/fmqUUkaBoioCuOZErM9xKiIV6bpNAhmw1RoWZ49u37q/vaiAg==}
+ '@tauri-apps/cli-linux-arm64-gnu@2.12.1':
+ resolution: {integrity: sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@tauri-apps/cli-linux-arm64-musl@2.12.0':
- resolution: {integrity: sha512-5tFuk3JKMBa1M/iN4gN32OYlSfhY3aqb5PO3zCugxaT+sfecyMKpc3EM9VDbhmwWBE9QD4Tfj7wGhlqx2yy2pQ==}
+ '@tauri-apps/cli-linux-arm64-musl@2.12.1':
+ resolution: {integrity: sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@tauri-apps/cli-linux-riscv64-gnu@2.12.0':
- resolution: {integrity: sha512-LOrMyV+nb7pKq8P0BZtTBQuyzm1ORjn16R9MlupmxuT5LqxtuYJlw3VMwbaeXCToJ8jbP5u9JnkfmbqDlvETXw==}
+ '@tauri-apps/cli-linux-riscv64-gnu@2.12.1':
+ resolution: {integrity: sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@tauri-apps/cli-linux-x64-gnu@2.12.0':
- resolution: {integrity: sha512-aMIkI+X9qvD2+k80QZB3tuOYT41/cEheikC510CwAIZ31KLXORsRASYYzM4mi1/58ICmtzSLFhCC08keir7COw==}
+ '@tauri-apps/cli-linux-x64-gnu@2.12.1':
+ resolution: {integrity: sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@tauri-apps/cli-linux-x64-musl@2.12.0':
- resolution: {integrity: sha512-9gl3NhUbfuGhSKjeGCypKSdtoezOQRcDUrkhTWtLQEjnajets6peO6NadgI2DF0OzbiiFOzrnnIGMwBS+Ap6Iw==}
+ '@tauri-apps/cli-linux-x64-musl@2.12.1':
+ resolution: {integrity: sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@tauri-apps/cli-win32-arm64-msvc@2.12.0':
- resolution: {integrity: sha512-1W+v86wsYLuyBDN0iDessD4CD9o+yNyO0m8Dp3+/vfjiZrJ/v7R7/aTRBeikaq2tLGgxeag0gxnFggBNBGw91A==}
+ '@tauri-apps/cli-win32-arm64-msvc@2.12.1':
+ resolution: {integrity: sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@tauri-apps/cli-win32-ia32-msvc@2.12.0':
- resolution: {integrity: sha512-SsBjvq4f7YaBGqcWoE3cycoItPf0SrQeqFqWWgfxoeZWAIz01E31LDSeZ2p/etJiIY0R9oi045chkFfb0O7WGw==}
+ '@tauri-apps/cli-win32-ia32-msvc@2.12.1':
+ resolution: {integrity: sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
- '@tauri-apps/cli-win32-x64-msvc@2.12.0':
- resolution: {integrity: sha512-9zzDKYUH4TKKBgRdDdVQX8fiRO5gJ8hD1nGsHU9DOHG5Jpm3arjbu9Xg1frR20EusG3Sl8X0dppoO/4EoUXUGg==}
+ '@tauri-apps/cli-win32-x64-msvc@2.12.1':
+ resolution: {integrity: sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
- '@tauri-apps/cli@2.12.0':
- resolution: {integrity: sha512-GIShnAR4C16kSTA8NO02fo8fOw/I5z5sAUEB5S2HlPHFnBkbihL4c9a/MpkxOm1+q5S+tFd3X4QUjHWxfyGzpQ==}
+ '@tauri-apps/cli@2.12.1':
+ resolution: {integrity: sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==}
engines: {node: '>= 10'}
hasBin: true
- '@tauri-apps/plugin-clipboard-manager@2.4.0':
- resolution: {integrity: sha512-yAuTffXc/sm+4oYnrwxbUkaJXQMTesPJ9dim0ADcA31rpboyhAN2TGQqs9VCcPIpb60LFOeePkvhpP2I3xhejg==}
+ '@tauri-apps/plugin-clipboard-manager@2.4.1':
+ resolution: {integrity: sha512-5Q9hWWnsE7UF1xABWHpRwqGiyORrP9o/rJift/7V8woP/kSVF21W+bP9G7ch8Qd7pneDWUMk6hkfpLpjcC/4Ag==}
- '@tauri-apps/plugin-deep-link@2.6.0':
- resolution: {integrity: sha512-41rOuYUZjxcEz71qLh1k9m7M2W6gQdWfvyYHuoKRTv1NMYQURTw+v2eItCa/efk+5PlEjtkjtTsoJQ2R9U5MCA==}
+ '@tauri-apps/plugin-deep-link@2.6.1':
+ resolution: {integrity: sha512-yomIOIoiiDwXW3Bgy/V5c4frWo9sfWM9WZQew2s7vr7hXtDdbLDCLzvytIMCjMxbnln9O0oJsTI3XdDd2T9XlA==}
- '@tauri-apps/plugin-dialog@2.8.0':
- resolution: {integrity: sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==}
+ '@tauri-apps/plugin-dialog@2.8.1':
+ resolution: {integrity: sha512-/DtE3B62JgpYuWFL5ATXM6KOXUq7pca7cJWOgSqULhZsgyjLk0PWKek1dbRDvhNBW6flNsOfWphsKnb+738Dmw==}
'@tauri-apps/plugin-fs@2.6.0':
resolution: {integrity: sha512-MhBAZ4rryd+h9fijd/GWDfArqiAeGI0ZNduV4wuRyUVI6wDZ+oDBY7atqtoXKDwlTUVSvpvmWJAY0ehQd/v6Uw==}
@@ -2641,8 +2624,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@26.6.3':
- resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==}
+ '@types/node@26.6.4':
+ resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -2997,11 +2980,6 @@ packages:
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
- peerDependencies:
- ajv: ^8.0.0
- peerDependenciesMeta:
- ajv:
- optional: true
ajv-keywords@3.5.2:
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
@@ -3080,7 +3058,7 @@ packages:
resolution: {integrity: sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.11.0 || ^8.0.0-0
babel-plugin-istanbul@8.0.0:
resolution: {integrity: sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==}
@@ -3093,13 +3071,13 @@ packages:
babel-preset-current-node-syntax@1.2.0:
resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.0.0 || ^8.0.0-0
babel-preset-jest@30.5.0:
resolution: {integrity: sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': ^7.11.0 || ^8.0.0-beta.1 || ^8.0.0
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
@@ -3587,7 +3565,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
- picomatch: '>=4.0.4'
+ picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
@@ -3630,8 +3608,8 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
- framer-motion@13.4.6:
- resolution: {integrity: sha512-TwKWt2CC8rjhARzwV00TZIh04ipumSAqyKM/JZmS+urdncSHg6wTF/mfj/Fd6Oi2W01TZSWsiA3uxXDrXZtKyw==}
+ framer-motion@14.0.0:
+ resolution: {integrity: sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -4170,8 +4148,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lucide-react@1.49.0:
- resolution: {integrity: sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==}
+ lucide-react@1.50.0:
+ resolution: {integrity: sha512-RqHPQtKX6S9IE1xOL4oTHHsVQbcwJWTnmlxI6AdI9IpOwKiy7q+COPCQyMepI/Qu1x3h7sunpyEMvqtgFbgNiQ==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -4321,14 +4299,14 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
- motion-dom@13.4.5:
- resolution: {integrity: sha512-p63p2Doq+US/O5wADauIPUVx0/eONHMLPzsudBM9XMWRuBQLXogdxH0vwIA8WYQZw864pyzoV723xoDqakwWTg==}
+ motion-dom@14.0.0:
+ resolution: {integrity: sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==}
- motion-utils@13.3.0:
- resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==}
+ motion-utils@14.0.0:
+ resolution: {integrity: sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==}
- motion@13.4.6:
- resolution: {integrity: sha512-zDXi6lIdyhda6wZlTsv9QgT2ed6UExAfRegtl/akQmZb5iaeLlArXr9Gmo/hx3iTx2y4R7tmdb9k9koFQmV+NQ==}
+ motion@14.0.0:
+ resolution: {integrity: sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -4375,8 +4353,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.3.7:
- resolution: {integrity: sha512-S4AlB0KMYcvVyEVjfD2Ze/3JsX9PWjpD3hJPcEJTVvkAQsMTYVs9DB1NtaBbs41ZZ18NxYwkmM8ljqtrFrmLsQ==}
+ next@16.3.8:
+ resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -4945,8 +4923,8 @@ packages:
resolution: {integrity: sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==}
engines: {node: '>=14.18.0'}
- supertest@7.3.0:
- resolution: {integrity: sha512-UwwmWq3xLhyU96c521wYNaBg7IqX78Wpj4FKs6Katp6vdklX42zU5ESvX3KeZRLFyO44RwFp4yvxYMNZzCcX9g==}
+ supertest@7.3.1:
+ resolution: {integrity: sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==}
engines: {node: '>=14.18.0'}
supports-color@7.2.0:
@@ -5003,7 +4981,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
- '@babel/core': '>=7.29.6 <8'
+ '@babel/core': '>=7.0.0-beta.0 <9'
'@jest/transform': ^29.0.0 || ^30.0.0
'@jest/types': ^29.0.0 || ^30.0.0
babel-jest: ^29.0.0 || ^30.0.0
@@ -5138,7 +5116,7 @@ packages:
resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
hasBin: true
peerDependencies:
- browserslist: '>=4.28.7'
+ browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -5266,7 +5244,7 @@ snapshots:
'@angular-devkit/core@22.2.0(chokidar@5.0.0)':
dependencies:
ajv: 8.20.0
- ajv-formats: 3.0.1(ajv@8.20.0)
+ ajv-formats: 3.0.1
jsonc-parser: 3.3.1
picomatch: 4.0.7
rxjs: 7.8.2
@@ -5274,11 +5252,11 @@ snapshots:
optionalDependencies:
chokidar: 5.0.0
- '@angular-devkit/schematics-cli@22.2.0(@types/node@26.6.3)(chokidar@5.0.0)':
+ '@angular-devkit/schematics-cli@22.2.0(@types/node@26.6.4)(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 22.2.0(chokidar@5.0.0)
'@angular-devkit/schematics': 22.2.0(chokidar@5.0.0)
- '@inquirer/prompts': 8.7.2(@types/node@26.6.3)
+ '@inquirer/prompts': 8.7.2(@types/node@26.6.4)
transitivePeerDependencies:
- '@types/node'
- chokidar
@@ -5301,7 +5279,7 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/client-s3@3.1143.0':
+ '@aws-sdk/client-s3@3.1145.0':
dependencies:
'@aws-sdk/checksums': 3.1001.1
'@aws-sdk/core': 3.978.1
@@ -5430,7 +5408,7 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/s3-request-presigner@3.1143.0':
+ '@aws-sdk/s3-request-presigner@3.1145.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.47
@@ -5753,39 +5731,39 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
- '@biomejs/biome@2.5.14':
+ '@biomejs/biome@2.5.15':
optionalDependencies:
- '@biomejs/cli-darwin-arm64': 2.5.14
- '@biomejs/cli-darwin-x64': 2.5.14
- '@biomejs/cli-linux-arm64': 2.5.14
- '@biomejs/cli-linux-arm64-musl': 2.5.14
- '@biomejs/cli-linux-x64': 2.5.14
- '@biomejs/cli-linux-x64-musl': 2.5.14
- '@biomejs/cli-win32-arm64': 2.5.14
- '@biomejs/cli-win32-x64': 2.5.14
+ '@biomejs/cli-darwin-arm64': 2.5.15
+ '@biomejs/cli-darwin-x64': 2.5.15
+ '@biomejs/cli-linux-arm64': 2.5.15
+ '@biomejs/cli-linux-arm64-musl': 2.5.15
+ '@biomejs/cli-linux-x64': 2.5.15
+ '@biomejs/cli-linux-x64-musl': 2.5.15
+ '@biomejs/cli-win32-arm64': 2.5.15
+ '@biomejs/cli-win32-x64': 2.5.15
- '@biomejs/cli-darwin-arm64@2.5.14':
+ '@biomejs/cli-darwin-arm64@2.5.15':
optional: true
- '@biomejs/cli-darwin-x64@2.5.14':
+ '@biomejs/cli-darwin-x64@2.5.15':
optional: true
- '@biomejs/cli-linux-arm64-musl@2.5.14':
+ '@biomejs/cli-linux-arm64-musl@2.5.15':
optional: true
- '@biomejs/cli-linux-arm64@2.5.14':
+ '@biomejs/cli-linux-arm64@2.5.15':
optional: true
- '@biomejs/cli-linux-x64-musl@2.5.14':
+ '@biomejs/cli-linux-x64-musl@2.5.15':
optional: true
- '@biomejs/cli-linux-x64@2.5.14':
+ '@biomejs/cli-linux-x64@2.5.15':
optional: true
- '@biomejs/cli-win32-arm64@2.5.14':
+ '@biomejs/cli-win32-arm64@2.5.15':
optional: true
- '@biomejs/cli-win32-x64@2.5.14':
+ '@biomejs/cli-win32-x64@2.5.15':
optional: true
'@borewit/text-codec@0.2.2': {}
@@ -5944,122 +5922,122 @@ snapshots:
'@inquirer/ansi@2.0.8': {}
- '@inquirer/checkbox@5.2.5(@types/node@26.6.3)':
+ '@inquirer/checkbox@5.2.5(@types/node@26.6.4)':
dependencies:
'@inquirer/ansi': 2.0.8
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
'@inquirer/figures': 2.0.9
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/confirm@6.3.2(@types/node@26.6.3)':
+ '@inquirer/confirm@6.3.2(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/core@12.0.3(@types/node@26.6.3)':
+ '@inquirer/core@12.0.3(@types/node@26.6.4)':
dependencies:
'@inquirer/ansi': 2.0.8
'@inquirer/figures': 2.0.9
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/editor@5.3.3(@types/node@26.6.3)':
+ '@inquirer/editor@5.3.3(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/external-editor': 3.0.5(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/external-editor': 3.0.5(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/expand@5.1.5(@types/node@26.6.3)':
+ '@inquirer/expand@5.1.5(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/external-editor@3.0.5(@types/node@26.6.3)':
+ '@inquirer/external-editor@3.0.5(@types/node@26.6.4)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.3
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@inquirer/figures@2.0.9': {}
- '@inquirer/input@5.1.6(@types/node@26.6.3)':
+ '@inquirer/input@5.1.6(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/number@4.2.3(@types/node@26.6.3)':
+ '@inquirer/number@4.2.3(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/password@5.2.2(@types/node@26.6.3)':
+ '@inquirer/password@5.2.2(@types/node@26.6.4)':
dependencies:
'@inquirer/ansi': 2.0.8
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/prompts@8.7.2(@types/node@26.6.3)':
+ '@inquirer/prompts@8.7.2(@types/node@26.6.4)':
dependencies:
- '@inquirer/checkbox': 5.2.5(@types/node@26.6.3)
- '@inquirer/confirm': 6.3.2(@types/node@26.6.3)
- '@inquirer/editor': 5.3.3(@types/node@26.6.3)
- '@inquirer/expand': 5.1.5(@types/node@26.6.3)
- '@inquirer/input': 5.1.6(@types/node@26.6.3)
- '@inquirer/number': 4.2.3(@types/node@26.6.3)
- '@inquirer/password': 5.2.2(@types/node@26.6.3)
- '@inquirer/rawlist': 5.3.5(@types/node@26.6.3)
- '@inquirer/search': 4.3.3(@types/node@26.6.3)
- '@inquirer/select': 5.2.5(@types/node@26.6.3)
+ '@inquirer/checkbox': 5.2.5(@types/node@26.6.4)
+ '@inquirer/confirm': 6.3.2(@types/node@26.6.4)
+ '@inquirer/editor': 5.3.3(@types/node@26.6.4)
+ '@inquirer/expand': 5.1.5(@types/node@26.6.4)
+ '@inquirer/input': 5.1.6(@types/node@26.6.4)
+ '@inquirer/number': 4.2.3(@types/node@26.6.4)
+ '@inquirer/password': 5.2.2(@types/node@26.6.4)
+ '@inquirer/rawlist': 5.3.5(@types/node@26.6.4)
+ '@inquirer/search': 4.3.3(@types/node@26.6.4)
+ '@inquirer/select': 5.2.5(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/rawlist@5.3.5(@types/node@26.6.3)':
+ '@inquirer/rawlist@5.3.5(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/search@4.3.3(@types/node@26.6.3)':
+ '@inquirer/search@4.3.3(@types/node@26.6.4)':
dependencies:
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
'@inquirer/figures': 2.0.9
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/select@5.2.5(@types/node@26.6.3)':
+ '@inquirer/select@5.2.5(@types/node@26.6.4)':
dependencies:
'@inquirer/ansi': 2.0.8
- '@inquirer/core': 12.0.3(@types/node@26.6.3)
+ '@inquirer/core': 12.0.3(@types/node@26.6.4)
'@inquirer/figures': 2.0.9
- '@inquirer/type': 4.1.1(@types/node@26.6.3)
+ '@inquirer/type': 4.1.1(@types/node@26.6.4)
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
- '@inquirer/type@4.1.1(@types/node@26.6.3)':
+ '@inquirer/type@4.1.1(@types/node@26.6.4)':
optionalDependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@isaacs/cliui@8.0.2':
dependencies:
@@ -6083,13 +6061,13 @@ snapshots:
'@jest/console@30.5.2':
dependencies:
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
jest-message-util: 30.5.1
jest-util: 30.5.1
slash: 3.0.0
- '@jest/core@30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))':
+ '@jest/core@30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))':
dependencies:
'@jest/console': 30.5.2
'@jest/pattern': 30.5.0
@@ -6097,7 +6075,7 @@ snapshots:
'@jest/test-result': 30.5.2
'@jest/transform': 30.5.2(supports-color@8.1.1)
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 4.4.0
@@ -6105,7 +6083,7 @@ snapshots:
fast-json-stable-stringify: 2.1.0
graceful-fs: 4.2.11
jest-changed-files: 30.5.1
- jest-config: 30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ jest-config: 30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
jest-haste-map: 30.5.1
jest-message-util: 30.5.1
jest-regex-util: 30.5.0
@@ -6131,7 +6109,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 30.5.2
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
jest-mock: 30.5.2
'@jest/expect-utils@30.5.2':
@@ -6149,7 +6127,7 @@ snapshots:
dependencies:
'@jest/types': 30.5.1
'@sinonjs/fake-timers': 15.4.0
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
jest-message-util: 30.5.1
jest-mock: 30.5.2
jest-util: 30.5.1
@@ -6167,7 +6145,7 @@ snapshots:
'@jest/pattern@30.5.0':
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
jest-regex-util: 30.5.0
'@jest/reporters@30.5.2(supports-color@8.1.1)':
@@ -6178,7 +6156,7 @@ snapshots:
'@jest/transform': 30.5.2(supports-color@8.1.1)
'@jest/types': 30.5.1
'@jridgewell/trace-mapping': 0.3.31
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit-x: 0.2.2
@@ -6255,7 +6233,7 @@ snapshots:
'@jest/schemas': 30.5.0
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -6303,12 +6281,12 @@ snapshots:
'@tybys/wasm-util': 0.10.4
optional: true
- '@nestjs/cli@12.0.8(@types/node@26.6.3)(fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)))(ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)))(tsconfig-paths-webpack-plugin@4.2.0)(webpack-node-externals@3.0.0)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))':
+ '@nestjs/cli@12.0.8(@types/node@26.6.4)(fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)))(ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)))(tsconfig-paths-webpack-plugin@4.2.0)(webpack-node-externals@3.0.0)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))':
dependencies:
'@angular-devkit/core': 22.2.0(chokidar@5.0.0)
'@angular-devkit/schematics': 22.2.0(chokidar@5.0.0)
- '@angular-devkit/schematics-cli': 22.2.0(@types/node@26.6.3)(chokidar@5.0.0)
- '@inquirer/prompts': 8.7.2(@types/node@26.6.3)
+ '@angular-devkit/schematics-cli': 22.2.0(@types/node@26.6.4)(chokidar@5.0.0)
+ '@inquirer/prompts': 8.7.2(@types/node@26.6.4)
'@nestjs/schematics': 12.0.6(chokidar@5.0.0)(typescript@6.0.3)
ansis: 4.4.0
chokidar: 5.0.0
@@ -6321,16 +6299,16 @@ snapshots:
tsconfig-paths: 4.2.0
typescript: 6.0.3
optionalDependencies:
- fork-ts-checker-webpack-plugin: 9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))
- ts-loader: 9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))
+ fork-ts-checker-webpack-plugin: 9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))
+ ts-loader: 9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))
tsconfig-paths-webpack-plugin: 4.2.0
- webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)
+ webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@types/node'
- prettier
- '@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)':
+ '@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)':
dependencies:
'@standard-schema/spec': 1.1.0
file-type: 22.1.1(supports-color@8.1.1)
@@ -6343,18 +6321,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@nestjs/config@12.0.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2)':
+ '@nestjs/config@12.0.1(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2)':
dependencies:
- '@nestjs/common': 12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
+ '@nestjs/common': 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@standard-schema/spec': 1.1.0
dotenv: 18.0.3
dotenv-expand: 13.0.0
es-toolkit: 1.52.0
rxjs: 7.8.2
- '@nestjs/core@12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ '@nestjs/core@12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
- '@nestjs/common': 12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
+ '@nestjs/common': 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
fast-safe-stringify: 2.1.1
iterare: 1.2.1
path-to-regexp: 8.4.2
@@ -6363,12 +6341,12 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
- '@nestjs/platform-express': 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(supports-color@8.1.1)
+ '@nestjs/platform-express': 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(supports-color@8.1.1)
- '@nestjs/platform-express@12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(supports-color@8.1.1)':
+ '@nestjs/platform-express@12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(supports-color@8.1.1)':
dependencies:
- '@nestjs/common': 12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
- '@nestjs/core': 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
+ '@nestjs/core': 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1(supports-color@8.1.1)
multer: 2.4.0
@@ -6388,38 +6366,38 @@ snapshots:
transitivePeerDependencies:
- chokidar
- '@nestjs/testing@12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(@nestjs/platform-express@12.1.1)':
+ '@nestjs/testing@12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(@nestjs/platform-express@12.1.2)':
dependencies:
- '@nestjs/common': 12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
- '@nestjs/core': 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
+ '@nestjs/core': 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
- '@nestjs/platform-express': 12.1.1(@nestjs/common@12.1.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.1)(supports-color@8.1.1)
+ '@nestjs/platform-express': 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.2)(supports-color@8.1.1)
- '@next/env@16.3.7': {}
+ '@next/env@16.3.8': {}
- '@next/swc-darwin-arm64@16.3.7':
+ '@next/swc-darwin-arm64@16.3.8':
optional: true
- '@next/swc-darwin-x64@16.3.7':
+ '@next/swc-darwin-x64@16.3.8':
optional: true
- '@next/swc-linux-arm64-gnu@16.3.7':
+ '@next/swc-linux-arm64-gnu@16.3.8':
optional: true
- '@next/swc-linux-arm64-musl@16.3.7':
+ '@next/swc-linux-arm64-musl@16.3.8':
optional: true
- '@next/swc-linux-x64-gnu@16.3.7':
+ '@next/swc-linux-x64-gnu@16.3.8':
optional: true
- '@next/swc-linux-x64-musl@16.3.7':
+ '@next/swc-linux-x64-musl@16.3.8':
optional: true
- '@next/swc-win32-arm64-msvc@16.3.7':
+ '@next/swc-win32-arm64-msvc@16.3.8':
optional: true
- '@next/swc-win32-x64-msvc@16.3.7':
+ '@next/swc-win32-x64-msvc@16.3.8':
optional: true
'@noble/hashes@1.8.0': {}
@@ -7399,78 +7377,78 @@ snapshots:
'@tanstack/virtual-core@3.17.11': {}
- '@tauri-apps/api@2.11.1': {}
+ '@tauri-apps/api@2.12.1': {}
- '@tauri-apps/cli-darwin-arm64@2.12.0':
+ '@tauri-apps/cli-darwin-arm64@2.12.1':
optional: true
- '@tauri-apps/cli-darwin-x64@2.12.0':
+ '@tauri-apps/cli-darwin-x64@2.12.1':
optional: true
- '@tauri-apps/cli-linux-arm-gnueabihf@2.12.0':
+ '@tauri-apps/cli-linux-arm-gnueabihf@2.12.1':
optional: true
- '@tauri-apps/cli-linux-arm64-gnu@2.12.0':
+ '@tauri-apps/cli-linux-arm64-gnu@2.12.1':
optional: true
- '@tauri-apps/cli-linux-arm64-musl@2.12.0':
+ '@tauri-apps/cli-linux-arm64-musl@2.12.1':
optional: true
- '@tauri-apps/cli-linux-riscv64-gnu@2.12.0':
+ '@tauri-apps/cli-linux-riscv64-gnu@2.12.1':
optional: true
- '@tauri-apps/cli-linux-x64-gnu@2.12.0':
+ '@tauri-apps/cli-linux-x64-gnu@2.12.1':
optional: true
- '@tauri-apps/cli-linux-x64-musl@2.12.0':
+ '@tauri-apps/cli-linux-x64-musl@2.12.1':
optional: true
- '@tauri-apps/cli-win32-arm64-msvc@2.12.0':
+ '@tauri-apps/cli-win32-arm64-msvc@2.12.1':
optional: true
- '@tauri-apps/cli-win32-ia32-msvc@2.12.0':
+ '@tauri-apps/cli-win32-ia32-msvc@2.12.1':
optional: true
- '@tauri-apps/cli-win32-x64-msvc@2.12.0':
+ '@tauri-apps/cli-win32-x64-msvc@2.12.1':
optional: true
- '@tauri-apps/cli@2.12.0':
+ '@tauri-apps/cli@2.12.1':
optionalDependencies:
- '@tauri-apps/cli-darwin-arm64': 2.12.0
- '@tauri-apps/cli-darwin-x64': 2.12.0
- '@tauri-apps/cli-linux-arm-gnueabihf': 2.12.0
- '@tauri-apps/cli-linux-arm64-gnu': 2.12.0
- '@tauri-apps/cli-linux-arm64-musl': 2.12.0
- '@tauri-apps/cli-linux-riscv64-gnu': 2.12.0
- '@tauri-apps/cli-linux-x64-gnu': 2.12.0
- '@tauri-apps/cli-linux-x64-musl': 2.12.0
- '@tauri-apps/cli-win32-arm64-msvc': 2.12.0
- '@tauri-apps/cli-win32-ia32-msvc': 2.12.0
- '@tauri-apps/cli-win32-x64-msvc': 2.12.0
+ '@tauri-apps/cli-darwin-arm64': 2.12.1
+ '@tauri-apps/cli-darwin-x64': 2.12.1
+ '@tauri-apps/cli-linux-arm-gnueabihf': 2.12.1
+ '@tauri-apps/cli-linux-arm64-gnu': 2.12.1
+ '@tauri-apps/cli-linux-arm64-musl': 2.12.1
+ '@tauri-apps/cli-linux-riscv64-gnu': 2.12.1
+ '@tauri-apps/cli-linux-x64-gnu': 2.12.1
+ '@tauri-apps/cli-linux-x64-musl': 2.12.1
+ '@tauri-apps/cli-win32-arm64-msvc': 2.12.1
+ '@tauri-apps/cli-win32-ia32-msvc': 2.12.1
+ '@tauri-apps/cli-win32-x64-msvc': 2.12.1
- '@tauri-apps/plugin-clipboard-manager@2.4.0':
+ '@tauri-apps/plugin-clipboard-manager@2.4.1':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
- '@tauri-apps/plugin-deep-link@2.6.0':
+ '@tauri-apps/plugin-deep-link@2.6.1':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
- '@tauri-apps/plugin-dialog@2.8.0':
+ '@tauri-apps/plugin-dialog@2.8.1':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
'@tauri-apps/plugin-fs@2.6.0':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
'@tauri-apps/plugin-log@2.10.0':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
'@tauri-apps/plugin-opener@2.7.0':
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
'@tokenizer/inflate@0.4.1(supports-color@8.1.1)':
dependencies:
@@ -7518,7 +7496,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/canvas-confetti@1.9.0': {}
@@ -7534,7 +7512,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/cookiejar@2.1.5': {}
@@ -7566,7 +7544,7 @@ snapshots:
'@types/express-serve-static-core@5.1.3':
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/qs': 6.15.1
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -7607,13 +7585,13 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/methods@1.1.4': {}
'@types/ms@2.1.0': {}
- '@types/node@26.6.3':
+ '@types/node@26.6.4':
dependencies:
undici-types: 8.9.0
@@ -7631,12 +7609,12 @@ snapshots:
'@types/send@1.2.1':
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@types/stack-utils@2.0.3': {}
@@ -7644,7 +7622,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
form-data: 4.0.6
'@types/supertest@7.2.1':
@@ -7896,8 +7874,8 @@ snapshots:
screenfull: 5.2.0
tslib: 2.8.1
- ajv-formats@3.0.1(ajv@8.20.0):
- optionalDependencies:
+ ajv-formats@3.0.1:
+ dependencies:
ajv: 8.20.0
ajv-keywords@3.5.2(ajv@6.15.0):
@@ -8498,7 +8476,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
- fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)):
+ fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)):
dependencies:
'@babel/code-frame': 7.29.7
chalk: 4.1.2
@@ -8513,7 +8491,7 @@ snapshots:
semver: 7.8.5
tapable: 2.3.3
typescript: 6.0.3
- webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)
+ webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)
optional: true
form-data@4.0.6:
@@ -8532,10 +8510,10 @@ snapshots:
forwarded@0.2.0: {}
- framer-motion@13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- motion-dom: 13.4.5
- motion-utils: 13.3.0
+ motion-dom: 14.0.0
+ motion-utils: 14.0.0
tslib: 2.8.1
optionalDependencies:
react: 19.3.0
@@ -8748,7 +8726,7 @@ snapshots:
'@jest/expect': 30.5.2(supports-color@8.1.1)
'@jest/test-result': 30.5.2
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.2
@@ -8768,15 +8746,15 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3)):
+ jest-cli@30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3)):
dependencies:
- '@jest/core': 30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ '@jest/core': 30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
'@jest/test-result': 30.5.2
'@jest/types': 30.5.1
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ jest-config: 30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
jest-util: 30.5.1
jest-validate: 30.5.1
yargs: 17.7.3
@@ -8787,7 +8765,7 @@ snapshots:
- supports-color
- ts-node
- jest-config@30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3)):
+ jest-config@30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3)):
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@jest/get-type': 30.5.0
@@ -8813,8 +8791,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 26.6.3
- ts-node: 10.9.2(@types/node@26.6.3)(typescript@6.0.3)
+ '@types/node': 26.6.4
+ ts-node: 10.9.2(@types/node@26.6.4)(typescript@6.0.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -8844,7 +8822,7 @@ snapshots:
'@jest/environment': 30.5.2
'@jest/fake-timers': 30.5.2
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
jest-mock: 30.5.2
jest-util: 30.5.1
jest-validate: 30.5.1
@@ -8853,7 +8831,7 @@ snapshots:
dependencies:
'@jest/types': 30.5.1
'@parcel/watcher': 2.6.0
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
anymatch: 3.1.3
fb-watchman: 2.0.2
fdir: 6.5.0(picomatch@4.0.7)
@@ -8892,7 +8870,7 @@ snapshots:
dependencies:
'@jest/expect-utils': 30.5.2
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
jest-util: 30.5.1
jest-regex-util@30.5.0: {}
@@ -8922,7 +8900,7 @@ snapshots:
'@jest/test-result': 30.5.2
'@jest/transform': 30.5.2(supports-color@8.1.1)
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
emittery: 0.13.1
exit-x: 0.2.2
@@ -8950,7 +8928,7 @@ snapshots:
'@jest/test-result': 30.5.2
'@jest/transform': 30.5.2(supports-color@8.1.1)
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
cjs-module-lexer: 2.2.1
collect-v8-coverage: 1.0.3
@@ -8998,7 +8976,7 @@ snapshots:
jest-util@30.5.1:
dependencies:
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
@@ -9017,7 +8995,7 @@ snapshots:
dependencies:
'@jest/test-result': 30.5.2
'@jest/types': 30.5.1
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -9026,24 +9004,24 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@30.5.1:
dependencies:
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
'@ungap/structured-clone': 1.4.0
jest-util: 30.5.1
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3)):
+ jest@30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3)):
dependencies:
- '@jest/core': 30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ '@jest/core': 30.5.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
'@jest/types': 30.5.1
import-local: 3.2.0
- jest-cli: 30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ jest-cli: 30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -9216,7 +9194,7 @@ snapshots:
dependencies:
yallist: 3.1.1
- lucide-react@1.49.0(@types/react@19.3.0)(react@19.3.0):
+ lucide-react@1.50.0(@types/react@19.3.0)(react@19.3.0):
dependencies:
react: 19.3.0
optionalDependencies:
@@ -9286,30 +9264,30 @@ snapshots:
minimist@1.2.8: {}
- minimizer-webpack-plugin@5.12.0(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)):
+ minimizer-webpack-plugin@5.12.0(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.5.0
terser: 5.51.2
- webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)
+ webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)
optionalDependencies:
lightningcss: 1.32.0
postcss: 8.5.28
- sharp: 0.35.5(@types/node@26.6.3)
+ sharp: 0.35.5(@types/node@26.6.4)
uglify-js: 3.19.3
minipass@7.1.3: {}
- motion-dom@13.4.5:
+ motion-dom@14.0.0:
dependencies:
- motion-utils: 13.3.0
+ motion-utils: 14.0.0
- motion-utils@13.3.0: {}
+ motion-utils@14.0.0: {}
- motion@13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- framer-motion: 13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ framer-motion: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
tslib: 2.8.1
optionalDependencies:
react: 19.3.0
@@ -9342,9 +9320,9 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- next@16.3.7(@babel/core@8.0.1)(@types/node@26.6.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.3.8(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- '@next/env': 16.3.7
+ '@next/env': 16.3.8
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.26
caniuse-lite: 1.0.30001814
@@ -9353,15 +9331,15 @@ snapshots:
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@8.0.1)(react@19.3.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.3.7
- '@next/swc-darwin-x64': 16.3.7
- '@next/swc-linux-arm64-gnu': 16.3.7
- '@next/swc-linux-arm64-musl': 16.3.7
- '@next/swc-linux-x64-gnu': 16.3.7
- '@next/swc-linux-x64-musl': 16.3.7
- '@next/swc-win32-arm64-msvc': 16.3.7
- '@next/swc-win32-x64-msvc': 16.3.7
- sharp: 0.35.5(@types/node@26.6.3)
+ '@next/swc-darwin-arm64': 16.3.8
+ '@next/swc-darwin-x64': 16.3.8
+ '@next/swc-linux-arm64-gnu': 16.3.8
+ '@next/swc-linux-arm64-musl': 16.3.8
+ '@next/swc-linux-x64-gnu': 16.3.8
+ '@next/swc-linux-x64-musl': 16.3.8
+ '@next/swc-win32-arm64-msvc': 16.3.8
+ '@next/swc-win32-x64-msvc': 16.3.8
+ sharp: 0.35.5(@types/node@26.6.4)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -9400,11 +9378,11 @@ snapshots:
dependencies:
ee-first: 1.1.1
- onborda@1.2.5(@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.3.7(@babel/core@8.0.1)(@types/node@26.6.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ onborda@1.2.5(@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.3.8(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
- framer-motion: 13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
- next: 16.3.7(@babel/core@8.0.1)(@types/node@26.6.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ framer-motion: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.8(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
@@ -9752,7 +9730,7 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
ajv: 8.20.0
- ajv-formats: 3.0.1(ajv@8.20.0)
+ ajv-formats: 3.0.1
ajv-keywords: 5.1.0(ajv@8.20.0)
screenfull@5.2.0: {}
@@ -9788,7 +9766,7 @@ snapshots:
setprototypeof@1.2.0: {}
- sharp@0.35.5(@types/node@26.6.3):
+ sharp@0.35.5(@types/node@26.6.4):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -9819,7 +9797,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
optional: true
shebang-command@2.0.0:
@@ -9972,7 +9950,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- supertest@7.3.0(supports-color@8.1.1):
+ supertest@7.3.1(supports-color@8.1.1):
dependencies:
cookie-signature: 1.2.2
methods: 1.1.2
@@ -10000,7 +9978,7 @@ snapshots:
tauri-plugin-macos-permissions-api@2.3.0:
dependencies:
- '@tauri-apps/api': 2.11.1
+ '@tauri-apps/api': 2.12.1
terser@5.51.2:
dependencies:
@@ -10027,12 +10005,12 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
- ts-jest@29.4.14(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.5.2(supports-color@8.1.1))(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3)))(typescript@6.0.3):
+ ts-jest@29.4.14(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.5.2(supports-color@8.1.1))(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3)))(typescript@6.0.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
- jest: 30.5.2(@types/node@26.6.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3))
+ jest: 30.5.2(@types/node@26.6.4)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -10047,22 +10025,22 @@ snapshots:
babel-jest: 30.5.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
jest-util: 30.5.1
- ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)):
+ ts-loader@9.6.2(typescript@6.0.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)):
dependencies:
chalk: 4.1.2
picomatch: 4.0.7
source-map: 0.7.6
typescript: 6.0.3
- webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)
+ webpack: 5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)
- ts-node@10.9.2(@types/node@26.6.3)(typescript@6.0.3):
+ ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.13
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
- '@types/node': 26.6.3
+ '@types/node': 26.6.4
acorn: 8.18.0
acorn-walk: 8.3.5
arg: 4.1.3
@@ -10257,7 +10235,7 @@ snapshots:
webpack-sources@3.6.0: {}
- webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3):
+ webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3):
dependencies:
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
@@ -10271,7 +10249,7 @@ snapshots:
events: 3.3.0
graceful-fs: 4.2.11
mime-db: 1.54.0
- minimizer-webpack-plugin: 5.12.0(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.3))(uglify-js@3.19.3))
+ minimizer-webpack-plugin: 5.12.0(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3)(webpack@5.111.1(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.5(@types/node@26.6.4))(uglify-js@3.19.3))
schema-utils: 4.5.0
tapable: 2.3.3
watchpack: 2.5.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0b32845e..49d31ad5 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -15,25 +15,10 @@ onlyBuiltDependencies:
# no longer reads that field; settings live here now.
overrides:
# Keep the API used by plugins on the Rust crate's minor version.
- '@tauri-apps/api': '~2.11.1'
- picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
- path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
- postcss@<8.5.18: '>=8.5.18'
- fast-xml-parser@<5.7.0: '>=5.7.2'
- fast-uri@<3.1.5: '>=3.1.5 <4'
- fast-xml-builder@<1.2.0: '>=1.2.0'
- qs@<6.16.0: '>=6.16.0'
- js-cookie@<3.0.7: '>=3.0.7'
- nanoid@<3.3.17: '>=3.3.17 <4'
- fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
- multer@>=2.0.0 <2.3.0: '>=2.3.0'
- form-data@>=4.0.0 <4.0.6: '>=4.0.6'
- js-yaml@<3.15.2: '>=3.15.2 <4'
- js-yaml@>=4.0.0 <4.3.2: '>=4.3.2 <5'
- browserslist@<4.28.7: '>=4.28.7'
- '@babel/core@<7.29.6': '>=7.29.6 <8'
+ '@tauri-apps/api': '~2.12.1'
+ # Fixes the brace-expansion advisories for every major still in the tree;
+ # patches/brace-expansion@5.0.12.patch keeps the older CommonJS imports working.
brace-expansion@<5.0.12: 5.0.12
- sharp@<0.35.0: '>=0.35.0 <0.36'
allowBuilds:
'@nestjs/core': true
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index e1461d6f..accea498 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -224,7 +224,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -370,13 +370,13 @@ dependencies = [
[[package]]
name = "async-recursion"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -930,9 +930,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.5.1"
+version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1391,7 +1391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -1649,7 +1649,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2033,7 +2033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2205,9 +2205,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
-version = "0.12.5"
+version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
+checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
dependencies = [
"bytemuck",
]
@@ -3572,9 +3572,9 @@ dependencies = [
[[package]]
name = "lazy_static"
-version = "1.5.0"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "lebe"
@@ -3614,9 +3614,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
@@ -3852,9 +3852,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.2.3"
+version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -4495,7 +4495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5470,7 +5470,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6474,9 +6474,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
-version = "2.12.0"
+version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb20ef8e6f97d6fe03039666cd82861f09a38b56291fa2f0f9fe140023f61c1a"
+checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
dependencies = [
"anyhow",
"bytes",
@@ -6604,9 +6604,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
-version = "2.4.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b19e1bb15ac03f61d773c8e12fe8bbb5ea60d0b00edf598b6b408411b4717e9f"
+checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a"
dependencies = [
"arboard",
"log",
@@ -6619,9 +6619,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
-version = "2.6.0"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d6b3bf88f2ec8b97c852c2fa1464dc13c0ba3fbfb24af4144f23fbd54c0f14"
+checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736"
dependencies = [
"dunce",
"plist",
@@ -6640,9 +6640,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
-version = "2.8.0"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7527c5fd1678032d116c1a82e5d609c67370e56f858ae6cfd376bbca37fe0490"
+checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
dependencies = [
"log",
"raw-window-handle",
@@ -6761,9 +6761,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
-version = "2.5.1"
+version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91baf4f5db4b008fc2bcada0f7d3a49b45d911f94575ac236f225ed67554f5c0"
+checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
dependencies = [
"serde",
"serde_json",
@@ -6897,10 +6897,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7051,9 +7051,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -7646,9 +7646,9 @@ dependencies = [
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -8016,7 +8016,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8437,7 +8437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8622,9 +8622,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 00774ac6..4b27f4be 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -77,7 +77,7 @@ flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
-uuid = { version = "1.23", features = ["v4", "serde"] }
+uuid = { version = "1.27", features = ["v4", "serde"] }
url = "2.5"
blake3 = "1"
globset = "0.4"
@@ -88,7 +88,7 @@ chrono-tz = "0.10"
axum = { version = "0.8.9", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.7", features = ["cors"] }
-rand = "0.10.2"
+rand = "0.10.3"
utoipa = { version = "6", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.3"
argon2 = "0.6"
@@ -103,7 +103,7 @@ sha2 = "0.11"
# alongside the `ring` this crate already ships through boringtun. Moving up is a
# build-toolchain decision for every platform, not a routine dependency refresh.
shadowsocks = { version = ">=1.24, <1.25", default-features = false, features = ["aead-cipher"] }
-hyper = { version = "1.10", features = ["full"] }
+hyper = { version = "1.11", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
@@ -138,6 +138,9 @@ nix = { version = "0.31", features = ["signal", "process"] }
# Reading the desktop's titlebar button layout for the in-app window controls.
[target.'cfg(target_os = "linux")'.dependencies]
+# Held on the minor that tao, wry and tray-icon use. gtk-sys declares
+# `links = "gtk-3"`, and Cargo refuses two crates with the same `links` value,
+# so this moves only when Tauri moves.
gtk = "0.18"
# Reading the source browser's " Safe Storage" secret during profile
# import, so its cookies and passwords can be re-encrypted for Wayfern.
@@ -180,7 +183,7 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3.27.0"
wiremock = "0.6"
-hyper = { version = "1.10", features = ["full"] }
+hyper = { version = "1.11", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index ced9ed4d..58ccd7da 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -15,6 +15,7 @@
"core:window:allow-is-maximized",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
+ "core:window:allow-request-user-attention",
{
"identifier": "opener:allow-open-url",
"allow": [
diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs
deleted file mode 100644
index a011431b..00000000
--- a/src-tauri/src/agent.rs
+++ /dev/null
@@ -1,1262 +0,0 @@
-//! Agent-run transport.
-//!
-//! An agent run is a goal the cloud pursues on one profile, either on this
-//! desktop (`desktop`) or on a leased host (`fleet`). NONE of the reasoning
-//! lives here: everything a run actually does is the cloud API's, and this side
-//! never sees it.
-//!
-//! This module is the wire plus the two things a client is uniquely able to
-//! check: that the profile the run names is actually on this machine, and that
-//! the goal is not empty. Everything else is asked for and rendered back.
-//!
-//! It also owns the bridge that turns the run's `text/event-stream` into Tauri
-//! events, because a run's steps are only observable through that stream — the
-//! create call answers `queued` and nothing more.
-
-use crate::cloud_errors::{self, BackendFailure, FailureCodes};
-use crate::remote_session::{jittered, reconnect_delay, SseDecoder};
-use serde::de::DeserializeOwned;
-use serde::{Deserialize, Serialize};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Mutex, OnceLock};
-use std::time::Duration;
-use tauri::AppHandle;
-
-const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
-const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
-
-/// How long a live run may say nothing before the socket is treated as gone.
-///
-/// Longer than the cookie-bot equivalent on purpose: a single agent step can be
-/// a slow page load followed by a model call, and cutting a healthy stream
-/// mid-thought would replay the whole transcript for nothing.
-const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
-
-/// Failure codes for the run routes.
-const RUN_CODES: FailureCodes = FailureCodes {
- bad_request: "AGENT_GOAL_INVALID",
- forbidden: "AGENT_NOT_ENTITLED",
- not_found: "AGENT_RUN_NOT_FOUND",
- conflict: "AGENT_RUN_NOT_CANCELLABLE",
-};
-
-/// Failure codes for the recipe routes.
-///
-/// Deliberately not the run set: a 404 here is a recipe someone deleted from
-/// another device, not a run id that was never the caller's, and telling a user
-/// editing a saved list that "that run does not exist" is worse than saying
-/// nothing. The backend sends its own `{"code":…}` body on every refusal it has
-/// a name for, so these only decide a bodyless status.
-const RECIPE_CODES: FailureCodes = FailureCodes {
- bad_request: "AGENT_RECIPE_INVALID",
- forbidden: "AGENT_NOT_ENTITLED",
- not_found: "AGENT_RECIPE_NOT_FOUND",
- conflict: "AGENT_RECIPE_INVALID",
-};
-
-/// Every agent call fails as a code the frontend can translate.
-///
-/// There is no `Other(String)` carrying backend English: a raw message reaches
-/// the user untranslated, which is the bug pattern the `{"code":…}` convention
-/// exists to block.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct AgentError(pub BackendFailure);
-
-impl AgentError {
- pub fn code(&self) -> &str {
- &self.0.code
- }
-
- pub fn status(&self) -> u16 {
- self.0.status
- }
-
- /// The `{"code":…,"params":{…}}` string a Tauri command returns.
- pub fn to_error_json(&self) -> String {
- self.0.to_error_json()
- }
-}
-
-impl std::fmt::Display for AgentError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.to_error_json())
- }
-}
-
-impl From for AgentError {
- fn from(failure: BackendFailure) -> Self {
- Self(failure)
- }
-}
-
-// --- Wire types -------------------------------------------------------------
-//
-// The backend speaks camelCase on this surface, so every type here does too and
-// the same spelling reaches the frontend unchanged. One shape end to end means
-// a contract change is a single edit rather than two translations of it.
-
-/// Where the run drives a browser.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum AgentTarget {
- /// This machine, through the desktop's own automation surface.
- Desktop,
- /// A leased host of the profile's operating system.
- Fleet,
-}
-
-impl AgentTarget {
- fn as_str(self) -> &'static str {
- match self {
- Self::Desktop => "desktop",
- Self::Fleet => "fleet",
- }
- }
-}
-
-/// Ceilings a run stops at rather than running until the money does.
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentBudgets {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub max_steps: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub max_wall_ms: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub max_tokens: Option,
-}
-
-impl AgentBudgets {
- fn is_empty(&self) -> bool {
- self.max_steps.is_none() && self.max_wall_ms.is_none() && self.max_tokens.is_none()
- }
-}
-
-/// One run, as the server describes it.
-///
-/// Every field but the id defaults. A run view that fails to decode is a blank
-/// page where a working one should be, and the desktop is a renderer here: a
-/// field the server adds, drops or renames must cost one missing line, never
-/// the whole surface.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentRunView {
- pub id: String,
- #[serde(default)]
- pub profile_id: String,
- #[serde(default)]
- pub target: String,
- #[serde(default)]
- pub platform: Option,
- #[serde(default)]
- pub goal: String,
- #[serde(default)]
- pub status: String,
- #[serde(default)]
- pub model: Option,
- #[serde(default)]
- pub effort: Option,
- #[serde(default)]
- pub budgets: Option,
- #[serde(default)]
- pub allowed_hosts: Option>,
- #[serde(default)]
- pub remote_session_id: Option,
- #[serde(default)]
- pub close_reason: Option,
- #[serde(default)]
- pub error_code: Option,
- #[serde(default)]
- pub result: Option,
- #[serde(default)]
- pub tokens_in: Option,
- #[serde(default)]
- pub tokens_out: Option,
- #[serde(default)]
- pub cost_usd: Option,
- #[serde(default)]
- pub steps: Option,
- #[serde(default)]
- pub created_at: Option,
- #[serde(default)]
- pub started_at: Option,
- #[serde(default)]
- pub ended_at: Option,
- #[serde(default)]
- pub updated_at: Option,
-}
-
-/// One entry in a run's transcript.
-///
-/// `rest` keeps whatever the step kind carries — a thought's text, a tool's
-/// name and arguments, an error's message. Flattened rather than enumerated
-/// because the tool set is the server's and grows without a desktop release;
-/// dropping the unknown half would render a tool step with nothing in it.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentStep {
- #[serde(default)]
- pub index: u32,
- #[serde(default)]
- pub at: Option,
- #[serde(default)]
- pub kind: String,
- #[serde(flatten)]
- pub rest: serde_json::Map,
-}
-
-/// A run plus everything it has done so far.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentRunDetail {
- #[serde(flatten)]
- pub run: AgentRunView,
- #[serde(default)]
- pub transcript: Vec,
-}
-
-/// One page of runs, newest first.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentRunPage {
- #[serde(default)]
- pub runs: Vec,
- #[serde(default)]
- pub next_cursor: Option,
-}
-
-/// One step of a recipe, in the shape the API validates.
-///
-/// Kept as raw JSON rather than a mirrored enum: the server owns the schema
-/// and refuses anything it does not recognise, so a second copy of the rules
-/// here would only add a way for the two to disagree. What this side does
-/// check is the shape a client can get wrong silently — a step must be an
-/// object naming a `type` the server knows, and a targeted step must carry
-/// exactly one of `selector` or `locator`.
-pub type RecipeStep = serde_json::Value;
-
-/// The step kinds the API accepts, in its own spelling.
-const RECIPE_STEP_TYPES: [&str; 9] = [
- "navigate",
- "click",
- "type",
- "waitFor",
- "extract",
- "pressKey",
- "scroll",
- "screenshot",
- "sleep",
-];
-
-/// The kinds that name an element, and so must carry exactly one target.
-const RECIPE_TARGETED_TYPES: [&str; 4] = ["click", "type", "waitFor", "extract"];
-
-/// A recipe is a task, not a program: the API's own ceiling.
-const MAX_RECIPE_STEPS: usize = 200;
-
-/// A saved goal: a name and the steps it expands to.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AgentRecipe {
- pub id: String,
- #[serde(default)]
- pub name: String,
- #[serde(default)]
- pub steps: Vec,
- #[serde(default)]
- pub created_at: Option,
- #[serde(default)]
- pub updated_at: Option,
-}
-
-/// Either spelling of the recipe list.
-///
-/// A bare array and a `{"recipes":[…]}` envelope are both plausible readings of
-/// the same route, and guessing wrong blanks the library with a decode error
-/// rather than showing the user their own saved goals.
-#[derive(Debug, Deserialize)]
-#[serde(untagged)]
-enum RecipeList {
- Enveloped { recipes: Vec },
- Bare(Vec),
-}
-
-impl RecipeList {
- fn into_vec(self) -> Vec {
- match self {
- Self::Enveloped { recipes } => recipes,
- Self::Bare(recipes) => recipes,
- }
- }
-}
-
-/// What the desktop asks for when it starts a run.
-#[derive(Debug, Clone, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct StartAgentRunInput {
- pub profile_id: String,
- pub target: AgentTarget,
- pub goal: String,
- /// Which operating system the leased host must run. Ignored for a desktop
- /// run, and resolved from the profile when a fleet run omits it.
- #[serde(default)]
- pub platform: Option,
- #[serde(default)]
- pub effort: Option,
- #[serde(default)]
- pub budgets: Option,
- #[serde(default)]
- pub allowed_hosts: Option>,
-}
-
-// --- Local preconditions ----------------------------------------------------
-
-/// The longest goal the desktop will send.
-///
-/// The server has its own ceiling; this one exists so a pasted document is
-/// refused here instead of spending a round trip to be told so.
-pub const MAX_GOAL_CHARS: usize = 4000;
-
-/// Refuse a run the server would certainly refuse, before it costs anything.
-///
-/// Only the two things a client can actually know: the profile is on this
-/// machine, and the goal says something. Everything else — entitlement, budget
-/// ceilings, concurrency — is the server's to judge, and second-guessing it
-/// here is how a client ends up refusing work the account is entitled to.
-fn validate_goal(goal: &str) -> Result {
- let trimmed = goal.trim();
- if trimmed.is_empty() || trimmed.chars().count() > MAX_GOAL_CHARS {
- return Err(serde_json::json!({ "code": "AGENT_GOAL_INVALID" }).to_string());
- }
- Ok(trimmed.to_string())
-}
-
-/// The local profile a run refers to.
-fn local_profile(profile_id: &str) -> Result {
- let profiles = crate::profile::manager::ProfileManager::instance()
- .list_profiles()
- .map_err(|e| {
- log::warn!("Agent run refused: profiles could not be read: {e}");
- serde_json::json!({ "code": "INTERNAL_ERROR" }).to_string()
- })?;
- profiles
- .into_iter()
- .find(|p| p.id.to_string() == profile_id)
- .ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())
-}
-
-/// Which operating system a fleet run needs a host for.
-///
-/// The caller's choice when it made one, else the profile's own OS: a leased
-/// host has to be the machine the profile was built for, so falling back to it
-/// is the answer rather than a refusal the user cannot act on.
-fn fleet_platform(
- requested: Option<&str>,
- profile: &crate::profile::types::BrowserProfile,
-) -> Result {
- let resolved = requested
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .map(str::to_string)
- .or_else(|| profile.host_os.clone())
- .unwrap_or_default();
- if crate::cookie_bot::BOT_PLATFORMS.contains(&resolved.as_str()) {
- return Ok(resolved);
- }
- Err(
- serde_json::json!({
- "code": "REMOTE_PLATFORM_UNSUPPORTED",
- "params": { "platform": resolved },
- })
- .to_string(),
- )
-}
-
-// --- Routes -----------------------------------------------------------------
-
-fn base() -> String {
- format!("{}/api/agent", crate::cloud_auth::CLOUD_API_URL)
-}
-
-/// Start a run. Answers the created run, normally `queued`.
-pub async fn start_run(input: StartAgentRunInput) -> Result {
- let goal = validate_goal(&input.goal)?;
- let profile = local_profile(&input.profile_id)?;
-
- let mut body = serde_json::Map::new();
- body.insert(
- "profileId".to_string(),
- serde_json::Value::String(input.profile_id.clone()),
- );
- body.insert(
- "target".to_string(),
- serde_json::Value::String(input.target.as_str().to_string()),
- );
- body.insert("goal".to_string(), serde_json::Value::String(goal));
- if input.target == AgentTarget::Fleet {
- body.insert(
- "platform".to_string(),
- serde_json::Value::String(fleet_platform(input.platform.as_deref(), &profile)?),
- );
- }
- if let Some(effort) = input
- .effort
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- {
- body.insert(
- "effort".to_string(),
- serde_json::Value::String(effort.to_string()),
- );
- }
- if let Some(budgets) = input.budgets.filter(|b| !b.is_empty()) {
- body.insert(
- "budgets".to_string(),
- serde_json::to_value(budgets).unwrap_or(serde_json::Value::Null),
- );
- }
- // An empty list is not the same as no list: `[]` would mean "navigate
- // nowhere", so only a non-empty allowlist is sent at all.
- let hosts = normalise_hosts(input.allowed_hosts.as_deref());
- if !hosts.is_empty() {
- body.insert(
- "allowedHosts".to_string(),
- serde_json::Value::Array(hosts.into_iter().map(serde_json::Value::String).collect()),
- );
- }
-
- request(
- reqwest::Method::POST,
- format!("{}/runs", base()),
- Vec::new(),
- Some(serde_json::Value::Object(body)),
- RUN_CODES,
- )
- .await
- .map_err(|e| agent_error("run start", e))
-}
-
-/// Trim, lowercase and de-duplicate an allowlist, dropping blanks.
-///
-/// Hosts are compared case-insensitively by every browser, so `Example.com` and
-/// `example.com` reaching the server as two entries only makes the refusal
-/// message longer.
-pub fn normalise_hosts(hosts: Option<&[String]>) -> Vec {
- let mut seen = Vec::new();
- for host in hosts.unwrap_or_default() {
- let host = host.trim().to_lowercase();
- if host.is_empty() || seen.contains(&host) {
- continue;
- }
- seen.push(host);
- }
- seen
-}
-
-/// One page of runs, newest first.
-pub async fn list_runs(limit: Option, cursor: Option<&str>) -> Result {
- let mut query = Vec::new();
- if let Some(n) = limit {
- query.push(("limit".to_string(), n.to_string()));
- }
- if let Some(c) = cursor.map(str::trim).filter(|s| !s.is_empty()) {
- query.push(("cursor".to_string(), c.to_string()));
- }
- request(
- reqwest::Method::GET,
- format!("{}/runs", base()),
- query,
- None,
- RUN_CODES,
- )
- .await
- .map_err(|e| agent_error("run list", e))
-}
-
-/// One run and everything it has done.
-pub async fn get_run(run_id: &str) -> Result {
- request(
- reqwest::Method::GET,
- format!("{}/runs/{}", base(), urlencoding::encode(run_id)),
- Vec::new(),
- None,
- RUN_CODES,
- )
- .await
- .map_err(|e| agent_error("run read", e))
-}
-
-/// Stop a run that has not finished.
-pub async fn cancel_run(run_id: &str) -> Result {
- request(
- reqwest::Method::POST,
- format!("{}/runs/{}/cancel", base(), urlencoding::encode(run_id)),
- Vec::new(),
- Some(serde_json::Value::Object(serde_json::Map::new())),
- RUN_CODES,
- )
- .await
- .map_err(|e| agent_error("run cancel", e))
-}
-
-/// Every saved goal this account has.
-pub async fn list_recipes() -> Result, String> {
- let list: RecipeList = request(
- reqwest::Method::GET,
- format!("{}/recipes", base()),
- Vec::new(),
- None,
- RECIPE_CODES,
- )
- .await
- .map_err(|e| agent_error("recipe list", e))?;
- Ok(list.into_vec())
-}
-
-/// Save a new one.
-pub async fn create_recipe(name: &str, steps: &[RecipeStep]) -> Result {
- let (name, steps) = validate_recipe(name, steps)?;
- request(
- reqwest::Method::POST,
- format!("{}/recipes", base()),
- Vec::new(),
- Some(serde_json::json!({ "name": name, "steps": steps })),
- RECIPE_CODES,
- )
- .await
- .map_err(|e| agent_error("recipe create", e))
-}
-
-/// Rename one, replace its steps, or both.
-pub async fn update_recipe(
- id: &str,
- name: &str,
- steps: &[RecipeStep],
-) -> Result {
- let (name, steps) = validate_recipe(name, steps)?;
- request(
- reqwest::Method::PATCH,
- format!("{}/recipes/{}", base(), urlencoding::encode(id)),
- Vec::new(),
- Some(serde_json::json!({ "name": name, "steps": steps })),
- RECIPE_CODES,
- )
- .await
- .map_err(|e| agent_error("recipe update", e))
-}
-
-/// Delete one.
-pub async fn delete_recipe(id: &str) -> Result {
- let outcome: Option = request(
- reqwest::Method::DELETE,
- format!("{}/recipes/{}", base(), urlencoding::encode(id)),
- Vec::new(),
- None,
- RECIPE_CODES,
- )
- .await
- .map_err(|e| agent_error("recipe delete", e))?;
- Ok(recipe_deleted(outcome))
-}
-
-fn recipe_deleted(outcome: Option) -> bool {
- outcome.and_then(|reply| reply.deleted).unwrap_or(true)
-}
-
-#[derive(Debug, Default, Deserialize)]
-struct RecipeDeleted {
- #[serde(default)]
- deleted: Option,
-}
-
-/// A recipe needs a name and at least one step.
-///
-/// `NAME_CANNOT_BE_EMPTY` rather than an agent-specific code: it is the same
-/// refusal the user already meets when naming a group or a proxy, and it is
-/// already translated everywhere.
-fn validate_recipe(name: &str, steps: &[RecipeStep]) -> Result<(String, Vec), String> {
- let name = name.trim().to_string();
- if name.is_empty() {
- return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
- }
- if steps.is_empty() || steps.len() > MAX_RECIPE_STEPS {
- return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string());
- }
- for step in steps {
- let Some(object) = step.as_object() else {
- return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string());
- };
- let Some(kind) = object.get("type").and_then(serde_json::Value::as_str) else {
- return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string());
- };
- if !RECIPE_STEP_TYPES.contains(&kind) {
- return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string());
- }
- if RECIPE_TARGETED_TYPES.contains(&kind) {
- let has_selector = object
- .get("selector")
- .and_then(serde_json::Value::as_str)
- .is_some_and(|selector| !selector.trim().is_empty());
- let has_locator = object
- .get("locator")
- .is_some_and(|locator| locator.as_object().is_some_and(|fields| !fields.is_empty()));
- // Exactly one, which is the rule the API enforces: both is ambiguous and
- // neither names nothing.
- if has_selector == has_locator {
- return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string());
- }
- }
- }
- Ok((name, steps.to_vec()))
-}
-
-/// Turn an agent failure into the code the frontend translates.
-fn agent_error(context: &str, err: AgentError) -> String {
- log::warn!(
- "Agent {context} failed: {} (HTTP {})",
- err.code(),
- err.status()
- );
- err.to_error_json()
-}
-
-// --- Step stream ------------------------------------------------------------
-
-/// One step was appended to a run. Payload: the SSE frame's own JSON.
-pub const EVENT_AGENT_STEP: &str = "agent-run-step";
-/// A run changed status. Payload: the SSE frame's own JSON.
-pub const EVENT_AGENT_STATUS: &str = "agent-run-status";
-/// Whether steps are currently arriving. Payload: `{connected, runId, reason}`.
-pub const EVENT_AGENT_STREAM: &str = "agent-run-stream";
-
-static STREAM_RUNNING: AtomicBool = AtomicBool::new(false);
-static WATCHED_RUN: Mutex> = Mutex::new(None);
-static STREAM_TASK: Mutex >> = Mutex::new(None);
-
-/// Granularity of the cancellable sleep, so a stop is not held up by a backoff.
-const SHUTDOWN_POLL: Duration = Duration::from_millis(250);
-
-/// A run that cannot move again on its own.
-///
-/// The stream ends after one of these, and a client that reconnected anyway
-/// would replay a finished transcript on a loop for as long as the page stayed
-/// open.
-pub fn is_terminal_status(status: &str) -> bool {
- matches!(status, "succeeded" | "failed" | "cancelled")
-}
-
-/// Which run the desktop is currently streaming, if any.
-pub fn watched_run() -> Option {
- if !STREAM_RUNNING.load(Ordering::SeqCst) {
- return None;
- }
- WATCHED_RUN.lock().ok().and_then(|slot| slot.clone())
-}
-
-/// Start streaming one run's steps.
-///
-/// Idempotent for the run already being watched; asking for a different one
-/// replaces the stream rather than opening a second socket, because the page
-/// only ever shows one run at a time and two sockets would double every step.
-pub fn start_run_events(app: AppHandle, run_id: String) {
- if watched_run().as_deref() == Some(run_id.as_str()) {
- return;
- }
- stop_run_events();
-
- if let Ok(mut slot) = WATCHED_RUN.lock() {
- *slot = Some(run_id.clone());
- }
- STREAM_RUNNING.store(true, Ordering::SeqCst);
- let handle = tauri::async_runtime::spawn(async move {
- run_step_events(app, run_id).await;
- });
- if let Ok(mut slot) = STREAM_TASK.lock() {
- *slot = Some(handle);
- }
-}
-
-/// Stop streaming. Safe to call when nothing is running.
-pub fn stop_run_events() {
- if let Ok(mut slot) = WATCHED_RUN.lock() {
- *slot = None;
- }
- if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
- return;
- }
- if let Ok(mut slot) = STREAM_TASK.lock() {
- if let Some(handle) = slot.take() {
- handle.abort();
- }
- }
-}
-
-async fn run_step_events(app: AppHandle, run_id: String) {
- let mut attempt = 0u32;
-
- while STREAM_RUNNING.load(Ordering::SeqCst) && watched_run().as_deref() == Some(run_id.as_str()) {
- match connect_run_events(&run_id).await {
- Ok(response) => {
- attempt = 0;
- emit_stream_status(&app, &run_id, true, None);
- match consume_run_events(&app, response).await {
- Ok(saw_terminal) => {
- emit_stream_status(&app, &run_id, false, None);
- if saw_terminal {
- // The run is over and the transcript will not grow again.
- // Reconnecting would replay it on a loop.
- log::info!("Agent run {run_id} reached a terminal status; stream closed");
- break;
- }
- log::info!("Agent run {run_id} stream closed by the backend");
- }
- Err(reason) => {
- log::warn!("Agent run {run_id} stream ended: {reason}");
- emit_stream_status(&app, &run_id, false, Some(&reason));
- }
- }
- }
- Err(reason) => {
- log::warn!("Agent run {run_id} stream could not connect: {reason}");
- emit_stream_status(&app, &run_id, false, Some(&reason));
- }
- }
-
- if !STREAM_RUNNING.load(Ordering::SeqCst) {
- break;
- }
- let delay = jittered(reconnect_delay(attempt));
- attempt = attempt.saturating_add(1);
- sleep_unless_stopped(delay).await;
- }
-
- // Only clear the slot when this task still owns it: a newer `start_run_events`
- // may already have installed its own run id, and blanking that would make the
- // status command lie about a stream that is very much alive.
- if let Ok(mut slot) = WATCHED_RUN.lock() {
- if slot.as_deref() == Some(run_id.as_str()) {
- *slot = None;
- STREAM_RUNNING.store(false, Ordering::SeqCst);
- }
- }
-}
-
-async fn sleep_unless_stopped(total: Duration) {
- let mut slept = Duration::ZERO;
- while slept < total && STREAM_RUNNING.load(Ordering::SeqCst) {
- let step = SHUTDOWN_POLL.min(total - slept);
- tokio::time::sleep(step).await;
- slept += step;
- }
-}
-
-async fn connect_run_events(run_id: &str) -> Result {
- let endpoint = format!("{}/runs/{}/events", base(), urlencoding::encode(run_id));
-
- crate::cloud_auth::CLOUD_AUTH
- .api_call_with_retry(|token| {
- let endpoint = endpoint.clone();
- async move {
- // Through api_call_with_retry so a token that expired during a long run
- // is refreshed on the reconnect instead of leaving the page silent.
- let response = crate::remote_session::stream_client()
- .get(&endpoint)
- .bearer_auth(token)
- .header(reqwest::header::ACCEPT, "text/event-stream")
- .header(reqwest::header::CACHE_CONTROL, "no-cache")
- .send()
- .await
- .map_err(|e| format!("reach backend: {e}"))?;
-
- let status = response.status().as_u16();
- if !(200..300).contains(&status) {
- let text = response.text().await.unwrap_or_default();
- return Err(format!("({status}) {text}"));
- }
- Ok(response)
- }
- })
- .await
- .map_err(|e| cloud_errors::classify_message(&e, RUN_CODES).code)
-}
-
-/// Read frames until the socket closes. `true` when the run finished first.
-async fn consume_run_events(app: &AppHandle, response: reqwest::Response) -> Result {
- use futures_util::StreamExt;
-
- let mut stream = response.bytes_stream();
- let mut decoder = SseDecoder::new();
- let mut saw_terminal = false;
-
- loop {
- if !STREAM_RUNNING.load(Ordering::SeqCst) {
- return Ok(saw_terminal);
- }
-
- let next = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await;
- let chunk = match next {
- // No ping. The socket is gone even though nothing errored, which is what
- // a machine returning from sleep sees.
- Err(_) => return Err("no heartbeat within the idle timeout".to_string()),
- Ok(None) => return Ok(saw_terminal),
- Ok(Some(Err(e))) => return Err(format!("stream error: {e}")),
- Ok(Some(Ok(bytes))) => bytes,
- };
-
- for frame in decoder.push(&chunk) {
- let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
- continue;
- };
- if target == EVENT_AGENT_STATUS && payload_is_terminal(&payload) {
- saw_terminal = true;
- }
- emit(app, target, payload);
- }
- }
-}
-
-fn payload_is_terminal(payload: &serde_json::Value) -> bool {
- payload
- .get("status")
- .and_then(serde_json::Value::as_str)
- .is_some_and(is_terminal_status)
-}
-
-/// Turn one decoded frame into the Tauri event and payload it becomes.
-///
-/// The backend names its frames (`step`, `status`, `ping`) AND repeats the name
-/// inside the JSON as `type`. Either is honoured, because a proxy that strips
-/// the `event:` line would otherwise turn every step into a dropped frame and
-/// the page would sit empty under a run that is visibly progressing.
-pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, serde_json::Value)> {
- if matches!(event, Some("ping" | "heartbeat" | "keepalive")) {
- return None;
- }
-
- let payload = match serde_json::from_str::(data) {
- Ok(value) => value,
- Err(e) => {
- log::warn!("Ignoring malformed agent event: {e}");
- return None;
- }
- };
- let object = payload.as_object()?;
-
- let kind = object
- .get("type")
- .and_then(serde_json::Value::as_str)
- .or(event)
- .unwrap_or_default();
-
- match kind {
- "step" => Some((EVENT_AGENT_STEP, payload)),
- "status" => Some((EVENT_AGENT_STATUS, payload)),
- "ping" | "heartbeat" | "keepalive" => None,
- other => {
- log::warn!("Ignoring agent frame of unknown kind: {other}");
- None
- }
- }
-}
-
-fn emit(app: &AppHandle, target: &str, payload: serde_json::Value) {
- use tauri::Emitter;
- if let Err(e) = app.emit(target, payload) {
- log::warn!("Failed to emit {target}: {e}");
- }
-}
-
-fn emit_stream_status(app: &AppHandle, run_id: &str, connected: bool, reason: Option<&str>) {
- emit(
- app,
- EVENT_AGENT_STREAM,
- serde_json::json!({ "connected": connected, "runId": run_id, "reason": reason }),
- );
-}
-
-// --- Transport --------------------------------------------------------------
-
-fn http() -> &'static reqwest::Client {
- static CLIENT: OnceLock = OnceLock::new();
- CLIENT.get_or_init(|| {
- reqwest::Client::builder()
- .timeout(REQUEST_TIMEOUT)
- .connect_timeout(CONNECT_TIMEOUT)
- .build()
- .unwrap_or_else(|_| reqwest::Client::new())
- })
-}
-
-/// One request, one place.
-///
-/// Goes through `api_call_with_retry` so an expired access token is refreshed
-/// and the call retried once — otherwise a user whose token aged out mid-run
-/// sees "not signed in" on a machine that is signed in.
-async fn request(
- method: reqwest::Method,
- url: String,
- query: Vec<(String, String)>,
- body: Option,
- codes: FailureCodes,
-) -> Result {
- crate::cloud_auth::CLOUD_AUTH
- .api_call_with_retry(|token| {
- let method = method.clone();
- let url = url.clone();
- let query = query.clone();
- let body = body.clone();
- async move {
- // Percent-encoded here rather than left to the HTTP client: a cursor
- // carrying a `&` must not be able to smuggle a second parameter into
- // the request. One implementation, shared with the cookie-bot wire.
- let url = crate::cookie_bot::with_query(&url, &query);
- let mut builder = http().request(method, &url).bearer_auth(token);
- if let Some(payload) = body {
- builder = builder.json(&payload);
- }
-
- let response = builder
- .send()
- .await
- .map_err(|e| format!("reach backend: {e}"))?;
-
- let status = response.status().as_u16();
- if !(200..300).contains(&status) {
- let text = response.text().await.unwrap_or_default();
- // Encode the status so api_call_with_retry can spot a 401 and
- // classify_message can recover the code afterwards.
- return Err(format!("({status}) {text}"));
- }
-
- let bytes = response
- .bytes()
- .await
- .map_err(|e| format!("decode response: {e}"))?;
- decode_body::(&bytes).map_err(|e| format!("decode response: {e}"))
- }
- })
- .await
- .map_err(|e| AgentError(cloud_errors::classify_message(&e, codes)))
-}
-
-fn decode_body(bytes: &[u8]) -> Result {
- if bytes.iter().all(u8::is_ascii_whitespace) {
- serde_json::from_slice(b"null")
- } else {
- serde_json::from_slice(bytes)
- }
-}
-
-// --- Tauri commands ---------------------------------------------------------
-//
-// Defined here rather than in `lib.rs` because every local precondition they
-// have lives in this file. They are registered in `generate_handler!` as
-// `agent::…`; unregistered they are unreachable and the page fails at runtime
-// with "command not found" rather than at build time.
-
-/// Start a run against a profile this machine holds.
-#[tauri::command]
-pub async fn start_agent_run(input: StartAgentRunInput) -> Result {
- start_run(input).await
-}
-
-/// One page of runs, newest first.
-#[tauri::command]
-pub async fn get_agent_runs(
- limit: Option,
- cursor: Option,
-) -> Result {
- list_runs(limit, cursor.as_deref()).await
-}
-
-/// One run and its transcript.
-#[tauri::command]
-pub async fn get_agent_run(run_id: String) -> Result {
- get_run(&run_id).await
-}
-
-/// Stop a run that has not finished.
-#[tauri::command]
-pub async fn cancel_agent_run(run_id: String) -> Result {
- cancel_run(&run_id).await
-}
-
-/// Every saved goal.
-#[tauri::command]
-pub async fn get_agent_recipes() -> Result, String> {
- list_recipes().await
-}
-
-/// Save a new one.
-#[tauri::command]
-pub async fn create_agent_recipe(
- name: String,
- steps: Vec,
-) -> Result {
- create_recipe(&name, &steps).await
-}
-
-/// Rename one or replace its steps.
-#[tauri::command]
-pub async fn update_agent_recipe(
- id: String,
- name: String,
- steps: Vec,
-) -> Result {
- update_recipe(&id, &name, &steps).await
-}
-
-/// Delete one.
-#[tauri::command]
-pub async fn delete_agent_recipe(id: String) -> Result {
- delete_recipe(&id).await
-}
-
-/// Stream one run's steps. Idempotent for the run already being watched.
-#[tauri::command]
-pub fn start_agent_run_events(app_handle: AppHandle, run_id: String) {
- start_run_events(app_handle, run_id);
-}
-
-/// Stop streaming. Safe when nothing is running.
-#[tauri::command]
-pub fn stop_agent_run_events() {
- stop_run_events();
-}
-
-/// Which run is being streamed, if any.
-///
-/// A page that mounts after the stream started has no `agent-run-stream` event
-/// to read, so this is how it decides whether to trust the live steps or fall
-/// back to re-reading the transcript.
-#[tauri::command]
-pub fn get_agent_run_events_status() -> Option {
- watched_run()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn a_recipe_delete_with_no_body_is_a_success() {
- let empty: Option = decode_body(b"").expect("a 204 has no body");
- assert!(recipe_deleted(empty));
- let blank: Option = decode_body(b" \n").expect("whitespace is no body");
- assert!(recipe_deleted(blank));
- let refused: Option =
- decode_body(br#"{"deleted":false}"#).expect("an explicit answer");
- assert!(!recipe_deleted(refused));
- let bare: Option = decode_body(b"{}").expect("an empty object");
- assert!(recipe_deleted(bare));
- }
-
- #[test]
- fn an_empty_body_is_still_a_failure_where_a_value_is_required() {
- assert!(decode_body::(b"").is_err());
- assert!(decode_body::(b"").is_err());
- assert!(decode_body::>(b"").is_err());
- }
-
- #[test]
- fn a_blank_goal_is_refused_before_anything_is_spent() {
- for goal in ["", " ", "\n\t "] {
- let err = validate_goal(goal).unwrap_err();
- assert!(err.contains("AGENT_GOAL_INVALID"), "{goal:?} -> {err}");
- }
- assert_eq!(validate_goal(" buy milk ").unwrap(), "buy milk");
- }
-
- #[test]
- fn a_goal_longer_than_the_ceiling_is_refused_here_rather_than_by_the_server() {
- let long = "a".repeat(MAX_GOAL_CHARS + 1);
- assert!(validate_goal(&long)
- .unwrap_err()
- .contains("AGENT_GOAL_INVALID"));
- // Counted in characters, not bytes: a goal written in Japanese is not three
- // times shorter than the same goal written in English.
- let wide = "あ".repeat(MAX_GOAL_CHARS);
- assert_eq!(
- validate_goal(&wide).unwrap().chars().count(),
- MAX_GOAL_CHARS
- );
- }
-
- #[test]
- fn an_allowlist_is_lowercased_deduplicated_and_stripped_of_blanks() {
- let hosts = vec![
- "Example.com".to_string(),
- " example.com ".to_string(),
- "".to_string(),
- " ".to_string(),
- "docs.example.com".to_string(),
- ];
- assert_eq!(
- normalise_hosts(Some(&hosts)),
- vec!["example.com".to_string(), "docs.example.com".to_string()]
- );
- assert!(normalise_hosts(None).is_empty());
- }
-
- #[test]
- fn a_recipe_needs_a_name_and_steps_the_api_will_accept() {
- let navigate = serde_json::json!({"type": "navigate", "url": "https://example.com"});
- assert!(validate_recipe(" ", std::slice::from_ref(&navigate))
- .unwrap_err()
- .contains("NAME_CANNOT_BE_EMPTY"));
- assert!(validate_recipe("nightly", &[])
- .unwrap_err()
- .contains("AGENT_RECIPE_INVALID"));
-
- // A step is an object naming a kind the API knows. The old shape — a bare
- // line of prose — is exactly what the API refuses, so it is refused here
- // rather than sent and rejected.
- for rejected in [
- serde_json::json!("open the shop"),
- serde_json::json!({"url": "https://example.com"}),
- serde_json::json!({"type": "teleport", "url": "https://example.com"}),
- ] {
- assert!(
- validate_recipe("nightly", std::slice::from_ref(&rejected))
- .unwrap_err()
- .contains("AGENT_RECIPE_INVALID"),
- "{rejected} must be refused"
- );
- }
-
- // A step that names an element carries exactly one target.
- let neither = serde_json::json!({"type": "click"});
- let both = serde_json::json!({
- "type": "click",
- "selector": "#buy",
- "locator": {"role": "button"}
- });
- for rejected in [neither, both] {
- assert!(validate_recipe("nightly", std::slice::from_ref(&rejected))
- .unwrap_err()
- .contains("AGENT_RECIPE_INVALID"));
- }
-
- let click = serde_json::json!({"type": "click", "locator": {"role": "button", "name": "Buy"}});
- let (name, steps) = validate_recipe(" nightly ", &[navigate.clone(), click.clone()]).unwrap();
- assert_eq!(name, "nightly");
- assert_eq!(steps, vec![navigate, click]);
- }
-
- #[test]
- fn a_fleet_run_falls_back_to_the_profile_operating_system() {
- let profile = crate::profile::types::BrowserProfile {
- host_os: Some("windows".to_string()),
- ..Default::default()
- };
- assert_eq!(fleet_platform(None, &profile).unwrap(), "windows");
- assert_eq!(fleet_platform(Some(" "), &profile).unwrap(), "windows");
- assert_eq!(fleet_platform(Some("linux"), &profile).unwrap(), "linux");
-
- let android = crate::profile::types::BrowserProfile {
- host_os: Some("android".to_string()),
- ..Default::default()
- };
- let err = fleet_platform(None, &android).unwrap_err();
- assert!(err.contains("REMOTE_PLATFORM_UNSUPPORTED"), "{err}");
- assert!(err.contains("android"), "{err}");
- }
-
- #[test]
- fn only_a_finished_run_ends_the_stream() {
- for status in ["succeeded", "failed", "cancelled"] {
- assert!(is_terminal_status(status), "{status}");
- }
- for status in ["queued", "running", ""] {
- assert!(!is_terminal_status(status), "{status}");
- }
- }
-
- #[test]
- fn steps_and_statuses_route_by_the_json_type_or_the_sse_name() {
- let step = r#"{"type":"step","runId":"r1","step":{"index":0,"kind":"thought"}}"#;
- assert_eq!(route_frame(Some("step"), step).unwrap().0, EVENT_AGENT_STEP);
- // A proxy that strips the event line must not cost the frontend its steps.
- assert_eq!(route_frame(None, step).unwrap().0, EVENT_AGENT_STEP);
- // ...and neither must a backend that stops repeating the type inside.
- assert_eq!(
- route_frame(Some("step"), r#"{"runId":"r1"}"#).unwrap().0,
- EVENT_AGENT_STEP
- );
-
- let status = r#"{"type":"status","runId":"r1","status":"running"}"#;
- assert_eq!(
- route_frame(Some("status"), status).unwrap().0,
- EVENT_AGENT_STATUS
- );
- }
-
- #[test]
- fn pings_and_nonsense_are_dropped_rather_than_emitted() {
- assert!(route_frame(Some("ping"), "").is_none());
- assert!(route_frame(None, r#"{"type":"ping"}"#).is_none());
- assert!(route_frame(Some("step"), "not json").is_none());
- assert!(route_frame(None, r#"{"type":"something-new"}"#).is_none());
- }
-
- #[test]
- fn a_terminal_status_frame_is_recognised_from_its_payload() {
- let (_, payload) =
- route_frame(Some("status"), r#"{"type":"status","status":"succeeded"}"#).unwrap();
- assert!(payload_is_terminal(&payload));
- let (_, running) =
- route_frame(Some("status"), r#"{"type":"status","status":"running"}"#).unwrap();
- assert!(!payload_is_terminal(&running));
- }
-
- #[test]
- fn a_run_view_survives_a_server_that_sends_almost_nothing() {
- // The desktop renders this; a decode failure is a blank page where a
- // working run should be.
- let view: AgentRunView = serde_json::from_str(r#"{"id":"run-1"}"#).unwrap();
- assert_eq!(view.id, "run-1");
- assert_eq!(view.status, "");
- assert!(view.budgets.is_none());
- }
-
- #[test]
- fn a_step_keeps_the_fields_this_release_has_never_heard_of() {
- let step: AgentStep = serde_json::from_str(
- r##"{"index":3,"at":"2026-01-01T00:00:00Z","kind":"tool","tool":"click","selector":"#buy"}"##,
- )
- .unwrap();
- assert_eq!(step.index, 3);
- assert_eq!(step.kind, "tool");
- assert_eq!(step.rest.get("tool").unwrap(), "click");
- assert_eq!(step.rest.get("selector").unwrap(), "#buy");
- }
-
- #[test]
- fn the_recipe_list_decodes_bare_or_enveloped() {
- let bare: RecipeList =
- serde_json::from_str(r#"[{"id":"a","name":"A","steps":["x"]}]"#).unwrap();
- assert_eq!(bare.into_vec().len(), 1);
- let enveloped: RecipeList =
- serde_json::from_str(r#"{"recipes":[{"id":"a","name":"A","steps":["x"]}]}"#).unwrap();
- assert_eq!(enveloped.into_vec()[0].id, "a");
- }
-
- #[test]
- fn budgets_only_travel_when_the_user_set_one() {
- assert!(AgentBudgets::default().is_empty());
- assert!(!AgentBudgets {
- max_steps: Some(10),
- ..Default::default()
- }
- .is_empty());
- // `skip_serializing_if` keeps an unset ceiling out of the body entirely,
- // so the server applies its own default rather than reading a null as zero.
- let json = serde_json::to_string(&AgentBudgets {
- max_steps: Some(10),
- ..Default::default()
- })
- .unwrap();
- assert_eq!(json, r#"{"maxSteps":10}"#);
- }
-}
diff --git a/src-tauri/src/agent_console.rs b/src-tauri/src/agent_console.rs
new file mode 100644
index 00000000..ee3ef6ae
--- /dev/null
+++ b/src-tauri/src/agent_console.rs
@@ -0,0 +1,1340 @@
+//! The person's side of connected MCP agents: who is connected, what they did,
+//! questions and help requests, notes, profile take-overs and a global pause.
+//! Memory only; nothing here is persisted.
+
+use serde::Serialize;
+use std::collections::{HashMap, VecDeque};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{LazyLock, Mutex};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+pub const EVENT_SESSION: &str = "agent-console-session";
+pub const EVENT_ACTIVITY: &str = "agent-console-activity";
+pub const EVENT_THREAD: &str = "agent-console-thread";
+pub const EVENT_STATE: &str = "agent-console-state";
+pub const EVENT_CLEARED: &str = "agent-console-cleared";
+
+pub const MAX_TEXT_CHARS: usize = 2000;
+const MAX_PROGRESS_CHARS: usize = 280;
+const MAX_CHOICES: usize = 8;
+const MAX_CHOICE_CHARS: usize = 120;
+const MAX_ACTIVITY: usize = 1000;
+const MAX_THREAD: usize = 2000;
+// Twice the MCP server's live-session cap, so only ended sessions overflow it.
+const MAX_SESSIONS: usize = 1024;
+const SNAPSHOT_SESSIONS: usize = 50;
+pub const DEFAULT_WAIT_SECS: u64 = 45;
+// The cloud relay answers a call with 503 after 90 s.
+pub const MAX_WAIT_SECS: u64 = 55;
+// Each waiting call holds one of the bridge's eight in-flight slots.
+const MAX_CONCURRENT_WAITS: usize = 4;
+// A note sent to all agents also reaches agents that connect shortly after.
+const BROADCAST_GRACE_MS: u64 = 10 * 60 * 1000;
+const STATE_EMIT_INTERVAL_MS: u64 = 2000;
+
+pub const HUMAN_TOOLS: [&str; 5] = [
+ "ask_human",
+ "request_human_help",
+ "wait_for_human",
+ "get_human_updates",
+ "report_progress",
+];
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentStatus {
+ pub message: String,
+ pub done: Option,
+ pub total: Option,
+ pub profile_id: Option,
+ pub updated_at: u64,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentSession {
+ pub session_id: String,
+ pub client_name: Option,
+ pub client_version: Option,
+ pub connected_at: u64,
+ pub last_seen_at: u64,
+ pub calls: u64,
+ pub errors: u64,
+ pub ended: bool,
+ pub status: Option,
+ #[serde(skip)]
+ joined: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentActivity {
+ pub id: u64,
+ pub at: u64,
+ pub session_id: Option,
+ pub tool: String,
+ pub profile_id: Option,
+ pub profile_count: Option,
+ pub ok: bool,
+ pub error_code: Option,
+ pub duration_ms: u64,
+ pub detail: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ThreadKind {
+ Question,
+ Help,
+ Note,
+ Progress,
+ Joined,
+ Left,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ThreadState {
+ Open,
+ Answered,
+ Dismissed,
+ Pending,
+ Delivered,
+ None,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentThreadItem {
+ pub id: u64,
+ pub at: u64,
+ pub kind: ThreadKind,
+ pub session_id: Option,
+ pub text: String,
+ pub profile_id: Option,
+ pub choices: Vec,
+ pub state: ThreadState,
+ pub answer: Option,
+ pub answered_at: Option,
+ pub delivered_to: Vec,
+ pub done: Option,
+ pub total: Option,
+ #[serde(skip)]
+ outcome_seen: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentHold {
+ pub profile_id: String,
+ pub since: u64,
+ pub note: Option,
+ pub request_id: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentsPause {
+ pub since: u64,
+ pub note: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AgentConsoleSnapshot {
+ pub sessions: Vec,
+ pub activity: Vec,
+ pub thread: Vec,
+ pub holds: Vec,
+ pub paused: Option,
+ pub quota: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct StatePayload {
+ holds: Vec,
+ paused: Option,
+ quota: Option,
+}
+
+/// What an agent's wait ends with.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RequestOutcome {
+ Pending,
+ Answered(String),
+ Done(Option),
+ Dismissed,
+}
+
+/// Why a tool call may not run right now.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Refusal {
+ Paused {
+ note: Option,
+ },
+ ProfileHeld {
+ profile_id: String,
+ note: Option,
+ },
+}
+
+#[derive(Default)]
+struct Console {
+ sessions: HashMap,
+ activity: VecDeque,
+ thread: VecDeque,
+ holds: HashMap,
+ paused: Option,
+ next_id: u64,
+ last_state_emit: u64,
+}
+
+impl Console {
+ fn next_id(&mut self) -> u64 {
+ self.next_id += 1;
+ self.next_id
+ }
+
+ fn holds_sorted(&self) -> Vec {
+ let mut holds: Vec = self.holds.values().cloned().collect();
+ holds.sort_by_key(|hold| hold.since);
+ holds
+ }
+
+ fn push_thread(&mut self, item: AgentThreadItem) -> AgentThreadItem {
+ self.thread.push_back(item.clone());
+ while self.thread.len() > MAX_THREAD {
+ // Open requests are never dropped; the oldest settled item goes first.
+ match self
+ .thread
+ .iter()
+ .position(|entry| entry.state != ThreadState::Open)
+ {
+ Some(index) => {
+ self.thread.remove(index);
+ }
+ None => break,
+ }
+ }
+ item
+ }
+
+ fn find_thread_mut(&mut self, id: u64) -> Option<&mut AgentThreadItem> {
+ self.thread.iter_mut().find(|item| item.id == id)
+ }
+
+ fn evict_sessions(&mut self) {
+ while self.sessions.len() > MAX_SESSIONS {
+ let Some(oldest) = self
+ .sessions
+ .values()
+ .min_by_key(|session| (!session.ended, session.calls > 0, session.last_seen_at))
+ .map(|session| session.session_id.clone())
+ else {
+ break;
+ };
+ self.sessions.remove(&oldest);
+ }
+ }
+}
+
+static CONSOLE: LazyLock> = LazyLock::new(|| Mutex::new(Console::default()));
+static WAITERS: AtomicUsize = AtomicUsize::new(0);
+static CHANGES: LazyLock> =
+ LazyLock::new(|| tokio::sync::watch::channel(0).0);
+
+fn console() -> std::sync::MutexGuard<'static, Console> {
+ CONSOLE
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0)
+}
+
+fn notify_waiters() {
+ CHANGES.send_modify(|version| *version = version.wrapping_add(1));
+}
+
+fn emit(event: &str, payload: &S) {
+ let _ = crate::events::emit(event, payload);
+}
+
+fn clean_text(text: &str, max: usize) -> Result {
+ let text = text.trim();
+ if text.chars().count() > max {
+ return Err(
+ serde_json::json!({ "code": "AGENT_TEXT_TOO_LONG", "params": { "max": max.to_string() } })
+ .to_string(),
+ );
+ }
+ Ok(text.to_string())
+}
+
+fn optional_text(text: Option) -> Result, String> {
+ match text {
+ Some(text) => {
+ let text = clean_text(&text, MAX_TEXT_CHARS)?;
+ Ok((!text.is_empty()).then_some(text))
+ }
+ None => Ok(None),
+ }
+}
+
+fn thread_item(kind: ThreadKind, at: u64) -> AgentThreadItem {
+ AgentThreadItem {
+ id: 0,
+ at,
+ kind,
+ session_id: None,
+ text: String::new(),
+ profile_id: None,
+ choices: Vec::new(),
+ state: ThreadState::None,
+ answer: None,
+ answered_at: None,
+ delivered_to: Vec::new(),
+ done: None,
+ total: None,
+ outcome_seen: false,
+ }
+}
+
+async fn emit_state(force: bool) {
+ let now = now_ms();
+ {
+ let mut state = console();
+ if !force && now.saturating_sub(state.last_state_emit) < STATE_EMIT_INTERVAL_MS {
+ return;
+ }
+ state.last_state_emit = now;
+ }
+ let quota = crate::automation_rate_limiter::automation_quota().await;
+ let payload = {
+ let state = console();
+ StatePayload {
+ holds: state.holds_sorted(),
+ paused: state.paused.clone(),
+ quota,
+ }
+ };
+ emit(EVENT_STATE, &payload);
+}
+
+// --- Called by the MCP server ------------------------------------------------
+
+pub fn session_started(
+ session_id: &str,
+ client_name: Option,
+ client_version: Option,
+) {
+ let now = now_ms();
+ let mut state = console();
+ state.sessions.insert(
+ session_id.to_string(),
+ AgentSession {
+ session_id: session_id.to_string(),
+ client_name: client_name.map(|name| name.chars().take(80).collect()),
+ client_version: client_version.map(|version| version.chars().take(40).collect()),
+ connected_at: now,
+ last_seen_at: now,
+ calls: 0,
+ errors: 0,
+ ended: false,
+ status: None,
+ joined: false,
+ },
+ );
+ state.evict_sessions();
+}
+
+pub fn session_ended(session_id: &str) {
+ let now = now_ms();
+ let (session, left) = {
+ let mut state = console();
+ let Some(session) = state.sessions.get_mut(session_id) else {
+ return;
+ };
+ session.ended = true;
+ let joined = session.joined;
+ let session = session.clone();
+ let left = joined.then(|| {
+ let mut item = thread_item(ThreadKind::Left, now);
+ item.session_id = Some(session_id.to_string());
+ item.id = state.next_id();
+ state.push_thread(item)
+ });
+ (session, left)
+ };
+ emit(EVENT_SESSION, &session);
+ if let Some(item) = left {
+ emit(EVENT_THREAD, &item);
+ }
+}
+
+fn targeted_profiles(arguments: &serde_json::Value) -> Vec {
+ let mut ids = Vec::new();
+ for key in ["profile_id", "leader_profile_id"] {
+ if let Some(id) = arguments.get(key).and_then(serde_json::Value::as_str) {
+ ids.push(id.to_string());
+ }
+ }
+ for key in ["profile_ids", "follower_profile_ids"] {
+ if let Some(list) = arguments.get(key).and_then(serde_json::Value::as_array) {
+ ids.extend(list.iter().filter_map(|v| v.as_str().map(str::to_string)));
+ }
+ }
+ ids
+}
+
+/// `passive` tools (reads and the human tools) are never refused.
+pub fn check_call(passive: bool, arguments: &serde_json::Value) -> Result<(), Refusal> {
+ refusal_for(&console(), passive, arguments)
+}
+
+fn refusal_for(
+ state: &Console,
+ passive: bool,
+ arguments: &serde_json::Value,
+) -> Result<(), Refusal> {
+ if passive {
+ return Ok(());
+ }
+ if let Some(pause) = &state.paused {
+ return Err(Refusal::Paused {
+ note: pause.note.clone(),
+ });
+ }
+ for profile_id in targeted_profiles(arguments) {
+ if let Some(hold) = state.holds.get(&profile_id) {
+ return Err(Refusal::ProfileHeld {
+ profile_id,
+ note: hold.note.clone(),
+ });
+ }
+ }
+ Ok(())
+}
+
+pub fn safe_detail(tool: &str, arguments: &serde_json::Value) -> Option {
+ match tool {
+ "navigate" => arguments
+ .get("url")
+ .and_then(serde_json::Value::as_str)
+ .and_then(|url| url::Url::parse(url).ok())
+ .and_then(|url| url.host_str().map(str::to_string)),
+ "type_text" | "type_by_index" | "type_locator" => arguments
+ .get("text")
+ .and_then(serde_json::Value::as_str)
+ .map(|text| text.chars().count().to_string()),
+ _ => None,
+ }
+}
+
+/// A human-channel call: the agent is alive, but it is not browser activity.
+pub fn touch_session(session_id: Option<&str>) {
+ let Some(sid) = session_id else {
+ return;
+ };
+ let now = now_ms();
+ let (session, joined) = {
+ let mut state = console();
+ let joined = mark_joined(&mut state, sid, now);
+ let session = state.sessions.get_mut(sid).map(|session| {
+ session.last_seen_at = now;
+ session.clone()
+ });
+ (session, joined)
+ };
+ if let Some(item) = joined {
+ emit(EVENT_THREAD, &item);
+ }
+ if let Some(session) = session {
+ emit(EVENT_SESSION, &session);
+ }
+}
+
+fn mark_joined(state: &mut Console, sid: &str, now: u64) -> Option {
+ let session = state.sessions.get_mut(sid)?;
+ if session.joined {
+ return None;
+ }
+ session.joined = true;
+ let mut item = thread_item(ThreadKind::Joined, now);
+ item.session_id = Some(sid.to_string());
+ item.id = state.next_id();
+ Some(state.push_thread(item))
+}
+
+pub struct CallRecord<'a> {
+ pub session_id: Option<&'a str>,
+ pub tool: &'a str,
+ pub arguments: &'a serde_json::Value,
+ pub error_code: Option,
+ pub duration_ms: u64,
+}
+
+pub async fn record_call(record: CallRecord<'_>) {
+ let now = now_ms();
+ let profiles = targeted_profiles(record.arguments);
+ let (profile_id, profile_count) = match profiles.len() {
+ 0 => (None, None),
+ 1 => (profiles.into_iter().next(), None),
+ n => (None, Some(n as u32)),
+ };
+ let ok = record.error_code.is_none();
+ let (entry, session, joined) = {
+ let mut state = console();
+ let id = state.next_id();
+ let entry = AgentActivity {
+ id,
+ at: now,
+ session_id: record.session_id.map(str::to_string),
+ tool: record.tool.to_string(),
+ profile_id,
+ profile_count,
+ ok,
+ error_code: record.error_code,
+ duration_ms: record.duration_ms,
+ detail: safe_detail(record.tool, record.arguments),
+ };
+ state.activity.push_back(entry.clone());
+ while state.activity.len() > MAX_ACTIVITY {
+ state.activity.pop_front();
+ }
+ let mut joined_item = None;
+ let session = match record.session_id {
+ Some(sid) => {
+ joined_item = mark_joined(&mut state, sid, now);
+ state.sessions.get_mut(sid).map(|session| {
+ session.last_seen_at = now;
+ session.calls += 1;
+ if !ok {
+ session.errors += 1;
+ }
+ session.clone()
+ })
+ }
+ None => None,
+ };
+ (entry, session, joined_item)
+ };
+ if let Some(item) = joined {
+ emit(EVENT_THREAD, &item);
+ }
+ if let Some(session) = session {
+ emit(EVENT_SESSION, &session);
+ }
+ emit(EVENT_ACTIVITY, &entry);
+ emit_state(false).await;
+}
+
+/// Notes this session has not seen yet, marked as delivered.
+pub fn take_notes(session_id: Option<&str>) -> Vec {
+ let Some(sid) = session_id else {
+ return Vec::new();
+ };
+ let mut delivered = Vec::new();
+ {
+ let mut state = console();
+ let Some(connected_at) = state.sessions.get(sid).map(|s| s.connected_at) else {
+ return Vec::new();
+ };
+ for item in state.thread.iter_mut() {
+ if item.kind != ThreadKind::Note || item.delivered_to.iter().any(|s| s == sid) {
+ continue;
+ }
+ let eligible = match &item.session_id {
+ Some(target) => target == sid,
+ None => item.at + BROADCAST_GRACE_MS >= connected_at,
+ };
+ if !eligible {
+ continue;
+ }
+ item.delivered_to.push(sid.to_string());
+ item.state = ThreadState::Delivered;
+ item.answered_at.get_or_insert_with(now_ms);
+ delivered.push(item.clone());
+ }
+ }
+ for item in &delivered {
+ emit(EVENT_THREAD, item);
+ }
+ delivered
+}
+
+pub fn note_text(note: &AgentThreadItem) -> String {
+ match ¬e.profile_id {
+ Some(profile_id) => format!(
+ "Message from the person at this computer (about profile {profile_id}): {}",
+ note.text
+ ),
+ None => format!("Message from the person at this computer: {}", note.text),
+ }
+}
+
+pub struct NewRequest {
+ pub session_id: Option,
+ pub kind: ThreadKind,
+ pub text: String,
+ pub profile_id: Option,
+ pub choices: Vec,
+}
+
+pub fn validate_question(
+ question: &str,
+ choices: &[String],
+) -> Result<(String, Vec), String> {
+ let question = question.trim();
+ if question.is_empty() {
+ return Err("question must not be empty".to_string());
+ }
+ if question.chars().count() > MAX_TEXT_CHARS {
+ return Err(format!(
+ "question is longer than {MAX_TEXT_CHARS} characters"
+ ));
+ }
+ if choices.len() > MAX_CHOICES {
+ return Err(format!("at most {MAX_CHOICES} choices"));
+ }
+ let mut cleaned = Vec::new();
+ for choice in choices {
+ let choice = choice.trim();
+ if choice.is_empty() || choice.chars().count() > MAX_CHOICE_CHARS {
+ return Err(format!(
+ "each choice must be 1 to {MAX_CHOICE_CHARS} characters"
+ ));
+ }
+ cleaned.push(choice.to_string());
+ }
+ Ok((question.to_string(), cleaned))
+}
+
+pub async fn open_request(request: NewRequest) -> u64 {
+ let now = now_ms();
+ let item = {
+ let mut state = console();
+ let mut item = thread_item(request.kind, now);
+ item.id = state.next_id();
+ item.session_id = request.session_id;
+ item.text = request.text;
+ item.profile_id = request.profile_id.clone();
+ item.choices = request.choices;
+ item.state = ThreadState::Open;
+ if request.kind == ThreadKind::Help {
+ if let Some(profile_id) = request.profile_id {
+ let hold = AgentHold {
+ profile_id: profile_id.clone(),
+ since: now,
+ note: None,
+ request_id: Some(item.id),
+ };
+ state.holds.insert(profile_id, hold);
+ }
+ }
+ state.push_thread(item)
+ };
+ emit(EVENT_THREAD, &item);
+ if request.kind == ThreadKind::Help {
+ emit_state(true).await;
+ }
+ item.id
+}
+
+pub fn request_exists(request_id: u64) -> bool {
+ console().thread.iter().any(|item| {
+ item.id == request_id && matches!(item.kind, ThreadKind::Question | ThreadKind::Help)
+ })
+}
+
+fn outcome_of(item: &AgentThreadItem) -> RequestOutcome {
+ match (item.kind, item.state) {
+ (_, ThreadState::Dismissed) => RequestOutcome::Dismissed,
+ (ThreadKind::Help, ThreadState::Answered) => RequestOutcome::Done(item.answer.clone()),
+ (_, ThreadState::Answered) => RequestOutcome::Answered(item.answer.clone().unwrap_or_default()),
+ _ => RequestOutcome::Pending,
+ }
+}
+
+fn current_outcome(request_id: u64) -> Option {
+ let mut state = console();
+ let item = state.find_thread_mut(request_id)?;
+ let outcome = outcome_of(item);
+ if outcome != RequestOutcome::Pending {
+ item.outcome_seen = true;
+ }
+ Some(outcome)
+}
+
+struct WaitSlot;
+
+impl WaitSlot {
+ fn acquire() -> Option {
+ if WAITERS.fetch_add(1, Ordering::SeqCst) >= MAX_CONCURRENT_WAITS {
+ WAITERS.fetch_sub(1, Ordering::SeqCst);
+ return None;
+ }
+ Some(WaitSlot)
+ }
+}
+
+impl Drop for WaitSlot {
+ fn drop(&mut self) {
+ WAITERS.fetch_sub(1, Ordering::SeqCst);
+ }
+}
+
+/// Wait up to `wait_secs` for the person to settle a request.
+pub async fn wait_for(request_id: u64, wait_secs: u64) -> Option {
+ let outcome = current_outcome(request_id)?;
+ if outcome != RequestOutcome::Pending || wait_secs == 0 {
+ return Some(outcome);
+ }
+ let Some(_slot) = WaitSlot::acquire() else {
+ return Some(RequestOutcome::Pending);
+ };
+ let mut changes = CHANGES.subscribe();
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(wait_secs.min(MAX_WAIT_SECS));
+ loop {
+ match tokio::time::timeout_at(deadline, changes.changed()).await {
+ Ok(Ok(())) => {
+ let outcome = current_outcome(request_id)?;
+ if outcome != RequestOutcome::Pending {
+ return Some(outcome);
+ }
+ }
+ _ => return current_outcome(request_id),
+ }
+ }
+}
+
+pub fn report_progress(
+ session_id: Option<&str>,
+ message: &str,
+ done: Option,
+ total: Option,
+ profile_id: Option,
+) {
+ let now = now_ms();
+ let message: String = message.trim().chars().take(MAX_PROGRESS_CHARS).collect();
+ let (session, item) = {
+ let mut state = console();
+ let session = session_id.and_then(|sid| {
+ state.sessions.get_mut(sid).map(|session| {
+ session.status = Some(AgentStatus {
+ message: message.clone(),
+ done,
+ total,
+ profile_id: profile_id.clone(),
+ updated_at: now,
+ });
+ session.last_seen_at = now;
+ session.clone()
+ })
+ });
+ let owner = session_id.map(str::to_string);
+ // Consecutive reports from one agent update a single line.
+ let reuse = state
+ .thread
+ .iter()
+ .rposition(|item| item.session_id == owner && item.kind != ThreadKind::Note)
+ .filter(|&index| state.thread[index].kind == ThreadKind::Progress);
+ let item = match reuse {
+ Some(index) => {
+ let item = &mut state.thread[index];
+ item.at = now;
+ item.text = message;
+ item.done = done;
+ item.total = total;
+ item.profile_id = profile_id;
+ item.clone()
+ }
+ None => {
+ let mut item = thread_item(ThreadKind::Progress, now);
+ item.id = state.next_id();
+ item.session_id = owner;
+ item.text = message;
+ item.done = done;
+ item.total = total;
+ item.profile_id = profile_id;
+ state.push_thread(item)
+ }
+ };
+ (session, item)
+ };
+ if let Some(session) = session {
+ emit(EVENT_SESSION, &session);
+ }
+ emit(EVENT_THREAD, &item);
+}
+
+/// Everything an agent should know before its next step.
+pub fn human_updates(session_id: Option<&str>) -> serde_json::Value {
+ let notes = take_notes(session_id);
+ let mut state = console();
+ let owner = session_id.map(str::to_string);
+ let mut answers = Vec::new();
+ for item in state.thread.iter_mut() {
+ if item.session_id != owner
+ || !matches!(item.kind, ThreadKind::Question | ThreadKind::Help)
+ || item.outcome_seen
+ {
+ continue;
+ }
+ let outcome = outcome_of(item);
+ if outcome == RequestOutcome::Pending {
+ continue;
+ }
+ item.outcome_seen = true;
+ answers.push(outcome_json(item.id, &outcome));
+ }
+ let open: Vec = state
+ .thread
+ .iter()
+ .filter(|item| item.session_id == owner && item.state == ThreadState::Open)
+ .map(|item| item.id)
+ .collect();
+ serde_json::json!({
+ "paused": state.paused.as_ref().map(|pause| serde_json::json!({ "note": pause.note })),
+ "held_profiles": state.holds_sorted().into_iter().map(|hold| serde_json::json!({
+ "profile_id": hold.profile_id,
+ "note": hold.note,
+ })).collect::>(),
+ "notes": notes.iter().map(|note| serde_json::json!({
+ "id": note.id,
+ "text": note.text,
+ "profile_id": note.profile_id,
+ })).collect::>(),
+ "answers": answers,
+ "open_request_ids": open,
+ })
+}
+
+pub fn outcome_json(request_id: u64, outcome: &RequestOutcome) -> serde_json::Value {
+ match outcome {
+ RequestOutcome::Pending => serde_json::json!({
+ "status": "pending",
+ "request_id": request_id,
+ "next": "Not answered yet. Call wait_for_human with this request_id to keep waiting, or continue with other work and check get_human_updates later.",
+ }),
+ RequestOutcome::Answered(answer) => serde_json::json!({
+ "status": "answered",
+ "request_id": request_id,
+ "answer": answer,
+ }),
+ RequestOutcome::Done(note) => serde_json::json!({
+ "status": "done",
+ "request_id": request_id,
+ "note": note,
+ "next": "The person handed the profile back. Re-read the page before you continue.",
+ }),
+ RequestOutcome::Dismissed => serde_json::json!({
+ "status": "dismissed",
+ "request_id": request_id,
+ "next": "The person dismissed this request. Do not ask the same thing again; choose another approach or skip this item.",
+ }),
+ }
+}
+
+pub fn reset() {
+ {
+ let mut state = console();
+ *state = Console::default();
+ }
+ notify_waiters();
+ let _ = crate::events::emit_empty(EVENT_CLEARED);
+}
+
+pub async fn snapshot() -> AgentConsoleSnapshot {
+ let quota = crate::automation_rate_limiter::automation_quota().await;
+ let state = console();
+ let mut sessions: Vec = state.sessions.values().cloned().collect();
+ sessions.sort_by_key(|session| std::cmp::Reverse(session.last_seen_at));
+ sessions.truncate(SNAPSHOT_SESSIONS);
+ AgentConsoleSnapshot {
+ sessions,
+ activity: state.activity.iter().cloned().collect(),
+ thread: state.thread.iter().cloned().collect(),
+ holds: state.holds_sorted(),
+ paused: state.paused.clone(),
+ quota,
+ }
+}
+
+// --- Human side ------------------------------------------------------------
+
+fn profile_exists(profile_id: &str) -> Result<(), String> {
+ let profiles = crate::profile::manager::ProfileManager::instance()
+ .list_profiles()
+ .map_err(|e| {
+ log::warn!("[agent-console] profiles could not be read: {e}");
+ crate::backend_error("INTERNAL_ERROR")
+ })?;
+ if profiles.iter().any(|p| p.id.to_string() == profile_id) {
+ Ok(())
+ } else {
+ Err(crate::backend_error("PROFILE_NOT_FOUND"))
+ }
+}
+
+async fn settle(
+ request_id: u64,
+ state_to: ThreadState,
+ answer: Option,
+) -> Result {
+ let now = now_ms();
+ let (item, released) = {
+ let mut state = console();
+ let item = state
+ .find_thread_mut(request_id)
+ .filter(|item| matches!(item.kind, ThreadKind::Question | ThreadKind::Help))
+ .ok_or_else(|| crate::backend_error("AGENT_REQUEST_NOT_FOUND"))?;
+ if item.state != ThreadState::Open {
+ return Err(crate::backend_error("AGENT_REQUEST_CLOSED"));
+ }
+ item.state = state_to;
+ item.answer = answer;
+ item.answered_at = Some(now);
+ let item = item.clone();
+ let mut released = false;
+ if item.kind == ThreadKind::Help {
+ if let Some(profile_id) = &item.profile_id {
+ if state
+ .holds
+ .get(profile_id)
+ .is_some_and(|hold| hold.request_id == Some(request_id))
+ {
+ state.holds.remove(profile_id);
+ released = true;
+ }
+ }
+ }
+ (item, released)
+ };
+ notify_waiters();
+ emit(EVENT_THREAD, &item);
+ if released {
+ emit_state(true).await;
+ }
+ Ok(item)
+}
+
+#[tauri::command]
+pub async fn get_agent_console() -> Result {
+ Ok(snapshot().await)
+}
+
+#[tauri::command]
+pub async fn answer_agent_request(
+ request_id: u64,
+ answer: String,
+) -> Result {
+ let answer = clean_text(&answer, MAX_TEXT_CHARS)?;
+ let is_help = console()
+ .thread
+ .iter()
+ .any(|item| item.id == request_id && item.kind == ThreadKind::Help);
+ if answer.is_empty() && !is_help {
+ return Err(crate::backend_error("AGENT_ANSWER_EMPTY"));
+ }
+ settle(
+ request_id,
+ ThreadState::Answered,
+ (!answer.is_empty()).then_some(answer),
+ )
+ .await
+}
+
+#[tauri::command]
+pub async fn dismiss_agent_request(request_id: u64) -> Result {
+ settle(request_id, ThreadState::Dismissed, None).await
+}
+
+#[tauri::command]
+pub async fn send_agent_note(
+ text: String,
+ session_id: Option,
+ profile_id: Option,
+) -> Result {
+ let text = clean_text(&text, MAX_TEXT_CHARS)?;
+ if text.is_empty() {
+ return Err(crate::backend_error("AGENT_NOTE_EMPTY"));
+ }
+ if let Some(profile_id) = &profile_id {
+ profile_exists(profile_id)?;
+ }
+ let item = {
+ let mut state = console();
+ if let Some(sid) = &session_id {
+ if !state.sessions.contains_key(sid) {
+ return Err(crate::backend_error("AGENT_SESSION_NOT_FOUND"));
+ }
+ }
+ let mut item = thread_item(ThreadKind::Note, now_ms());
+ item.id = state.next_id();
+ item.session_id = session_id;
+ item.text = text;
+ item.profile_id = profile_id;
+ item.state = ThreadState::Pending;
+ state.push_thread(item)
+ };
+ emit(EVENT_THREAD, &item);
+ Ok(item)
+}
+
+#[tauri::command]
+pub async fn take_over_profile(
+ profile_id: String,
+ note: Option,
+) -> Result {
+ let note = optional_text(note)?;
+ profile_exists(&profile_id)?;
+ let hold = {
+ let mut state = console();
+ let hold = state
+ .holds
+ .entry(profile_id.clone())
+ .and_modify(|hold| {
+ if note.is_some() {
+ hold.note = note.clone();
+ }
+ })
+ .or_insert_with(|| AgentHold {
+ profile_id: profile_id.clone(),
+ since: now_ms(),
+ note: note.clone(),
+ request_id: None,
+ });
+ hold.clone()
+ };
+ emit_state(true).await;
+ Ok(hold)
+}
+
+#[tauri::command]
+pub async fn hand_back_profile(profile_id: String, note: Option) -> Result<(), String> {
+ let note = optional_text(note)?;
+ let removed = console().holds.remove(&profile_id);
+ let Some(hold) = removed else {
+ return Ok(());
+ };
+ if let Some(request_id) = hold.request_id {
+ let still_open = console()
+ .thread
+ .iter()
+ .any(|item| item.id == request_id && item.state == ThreadState::Open);
+ if still_open {
+ settle(request_id, ThreadState::Answered, note.clone()).await?;
+ }
+ } else if let Some(text) = note {
+ let item = {
+ let mut state = console();
+ let mut item = thread_item(ThreadKind::Note, now_ms());
+ item.id = state.next_id();
+ item.text = text;
+ item.profile_id = Some(profile_id.clone());
+ item.state = ThreadState::Pending;
+ state.push_thread(item)
+ };
+ emit(EVENT_THREAD, &item);
+ }
+ notify_waiters();
+ emit_state(true).await;
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn set_agents_paused(
+ paused: bool,
+ note: Option,
+) -> Result, String> {
+ let note = optional_text(note)?;
+ let pause = {
+ let mut state = console();
+ state.paused = paused.then(|| AgentsPause {
+ since: now_ms(),
+ note,
+ });
+ state.paused.clone()
+ };
+ notify_waiters();
+ emit_state(true).await;
+ Ok(pause)
+}
+
+#[tauri::command]
+pub async fn clear_agent_activity() -> Result<(), String> {
+ console().activity.clear();
+ let _ = crate::events::emit_empty(EVENT_CLEARED);
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn show_profile_window(profile_id: String) -> Result<(), String> {
+ profile_exists(&profile_id)?;
+ crate::mcp_server::McpServer::instance()
+ .bring_profile_to_front(&profile_id)
+ .await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique(prefix: &str) -> String {
+ format!("{prefix}-{}", uuid::Uuid::new_v4())
+ }
+
+ #[test]
+ fn details_never_carry_text_or_full_urls() {
+ assert_eq!(
+ safe_detail(
+ "navigate",
+ &serde_json::json!({"url": "https://shop.example.com/login?token=secret"})
+ )
+ .as_deref(),
+ Some("shop.example.com")
+ );
+ assert_eq!(
+ safe_detail("type_text", &serde_json::json!({"text": "hunter2"})).as_deref(),
+ Some("7")
+ );
+ assert_eq!(
+ safe_detail("evaluate_javascript", &serde_json::json!({"script": "1"})),
+ None
+ );
+ assert_eq!(
+ safe_detail("navigate", &serde_json::json!({"url": "not a url"})),
+ None
+ );
+ }
+
+ #[test]
+ fn every_profile_argument_counts_as_a_target() {
+ let targets = targeted_profiles(&serde_json::json!({
+ "profile_id": "a",
+ "profile_ids": ["b", "c"],
+ "leader_profile_id": "d",
+ "follower_profile_ids": ["e"],
+ }));
+ assert_eq!(targets, vec!["a", "d", "b", "c", "e"]);
+ }
+
+ #[test]
+ fn questions_and_choices_are_bounded() {
+ assert!(validate_question(" ", &[]).is_err());
+ assert!(validate_question(&"a".repeat(MAX_TEXT_CHARS + 1), &[]).is_err());
+ assert!(validate_question("ok?", &vec!["x".to_string(); MAX_CHOICES + 1]).is_err());
+ assert!(validate_question("ok?", &[" ".to_string()]).is_err());
+ let (question, choices) = validate_question(" Which one? ", &[" A ".to_string()]).unwrap();
+ assert_eq!(question, "Which one?");
+ assert_eq!(choices, vec!["A"]);
+ }
+
+ #[test]
+ fn a_pause_refuses_active_calls_but_not_passive_ones() {
+ let mut state = Console {
+ paused: Some(AgentsPause {
+ since: 1,
+ note: Some("lunch".to_string()),
+ }),
+ ..Default::default()
+ };
+ assert_eq!(
+ refusal_for(&state, false, &serde_json::json!({})),
+ Err(Refusal::Paused {
+ note: Some("lunch".to_string())
+ })
+ );
+ assert!(refusal_for(&state, true, &serde_json::json!({})).is_ok());
+ state.paused = None;
+ state.holds.insert(
+ "p1".to_string(),
+ AgentHold {
+ profile_id: "p1".to_string(),
+ since: 1,
+ note: None,
+ request_id: None,
+ },
+ );
+ assert!(matches!(
+ refusal_for(&state, false, &serde_json::json!({"profile_ids": ["p2", "p1"]})),
+ Err(Refusal::ProfileHeld { profile_id, .. }) if profile_id == "p1"
+ ));
+ assert!(refusal_for(&state, false, &serde_json::json!({"profile_id": "p2"})).is_ok());
+ }
+
+ #[tokio::test]
+ async fn a_help_request_holds_the_profile_until_the_person_answers() {
+ let session = unique("session");
+ let profile = unique("profile");
+ session_started(
+ &session,
+ Some("Claude Code".to_string()),
+ Some("2.1".to_string()),
+ );
+ let id = open_request(NewRequest {
+ session_id: Some(session.clone()),
+ kind: ThreadKind::Help,
+ text: "Solve the captcha".to_string(),
+ profile_id: Some(profile.clone()),
+ choices: Vec::new(),
+ })
+ .await;
+ assert!(matches!(
+ check_call(false, &serde_json::json!({"profile_id": profile})),
+ Err(Refusal::ProfileHeld { .. })
+ ));
+ assert_eq!(wait_for(id, 0).await, Some(RequestOutcome::Pending));
+
+ let waiter = tokio::spawn(wait_for(id, 5));
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ answer_agent_request(id, "done, you are in".to_string())
+ .await
+ .unwrap();
+ assert_eq!(
+ waiter.await.unwrap(),
+ Some(RequestOutcome::Done(Some("done, you are in".to_string())))
+ );
+ assert!(!console().holds.contains_key(&profile));
+ assert_eq!(
+ answer_agent_request(id, "again".to_string())
+ .await
+ .unwrap_err(),
+ crate::backend_error("AGENT_REQUEST_CLOSED")
+ );
+ let updates = human_updates(Some(&session));
+ assert_eq!(updates["answers"], serde_json::json!([]));
+ }
+
+ #[tokio::test]
+ async fn a_question_needs_an_answer_and_can_be_dismissed() {
+ let session = unique("session");
+ session_started(&session, None, None);
+ let id = open_request(NewRequest {
+ session_id: Some(session.clone()),
+ kind: ThreadKind::Question,
+ text: "Which account?".to_string(),
+ profile_id: None,
+ choices: vec!["A".to_string(), "B".to_string()],
+ })
+ .await;
+ assert_eq!(
+ answer_agent_request(id, " ".to_string())
+ .await
+ .unwrap_err(),
+ crate::backend_error("AGENT_ANSWER_EMPTY")
+ );
+ dismiss_agent_request(id).await.unwrap();
+ let updates = human_updates(Some(&session));
+ assert_eq!(updates["answers"][0]["status"], "dismissed");
+ assert_eq!(wait_for(id, 1).await, Some(RequestOutcome::Dismissed));
+ assert_eq!(
+ dismiss_agent_request(u64::MAX).await.unwrap_err(),
+ crate::backend_error("AGENT_REQUEST_NOT_FOUND")
+ );
+ }
+
+ #[tokio::test]
+ async fn notes_reach_their_agent_once() {
+ let first = unique("session");
+ let second = unique("session");
+ session_started(&first, None, None);
+ session_started(&second, None, None);
+ let direct = send_agent_note("only first".to_string(), Some(first.clone()), None)
+ .await
+ .unwrap();
+ let broadcast = send_agent_note("everyone".to_string(), None, None)
+ .await
+ .unwrap();
+ let ours = |notes: Vec| -> Vec {
+ notes
+ .into_iter()
+ .map(|n| n.id)
+ .filter(|id| *id == direct.id || *id == broadcast.id)
+ .collect()
+ };
+ assert_eq!(
+ ours(take_notes(Some(&first))),
+ vec![direct.id, broadcast.id]
+ );
+ assert!(ours(take_notes(Some(&first))).is_empty());
+ assert_eq!(ours(take_notes(Some(&second))), vec![broadcast.id]);
+ assert!(take_notes(None).is_empty());
+ assert_eq!(
+ send_agent_note("x".to_string(), Some(unique("missing")), None)
+ .await
+ .unwrap_err(),
+ crate::backend_error("AGENT_SESSION_NOT_FOUND")
+ );
+ assert_eq!(
+ send_agent_note(" ".to_string(), None, None)
+ .await
+ .unwrap_err(),
+ crate::backend_error("AGENT_NOTE_EMPTY")
+ );
+ }
+
+ #[tokio::test]
+ async fn progress_reports_update_one_line_per_agent() {
+ let session = unique("session");
+ session_started(&session, None, None);
+ report_progress(Some(&session), "Checking", Some(1), Some(10), None);
+ report_progress(Some(&session), "Checking", Some(2), Some(10), None);
+ let state = console();
+ let progress: Vec<_> = state
+ .thread
+ .iter()
+ .filter(|item| {
+ item.kind == ThreadKind::Progress && item.session_id.as_deref() == Some(&session)
+ })
+ .collect();
+ assert_eq!(progress.len(), 1);
+ assert_eq!(progress[0].done, Some(2));
+ assert_eq!(
+ state.sessions[&session]
+ .status
+ .as_ref()
+ .and_then(|s| s.done),
+ Some(2)
+ );
+ }
+
+ #[tokio::test]
+ async fn the_first_call_marks_a_session_as_joined_and_counts_errors() {
+ let session = unique("session");
+ session_started(&session, Some("Cursor".to_string()), None);
+ let arguments = serde_json::json!({"profile_id": "p1", "url": "https://example.com/a"});
+ record_call(CallRecord {
+ session_id: Some(&session),
+ tool: "navigate",
+ arguments: &arguments,
+ error_code: None,
+ duration_ms: 12,
+ })
+ .await;
+ record_call(CallRecord {
+ session_id: Some(&session),
+ tool: "click_locator",
+ arguments: &arguments,
+ error_code: Some("LOCATOR_NO_MATCH".to_string()),
+ duration_ms: 30,
+ })
+ .await;
+ session_ended(&session);
+ let state = console();
+ let activity: Vec<_> = state
+ .activity
+ .iter()
+ .filter(|entry| entry.session_id.as_deref() == Some(&session))
+ .collect();
+ assert_eq!(activity.len(), 2);
+ assert_eq!(activity[0].detail.as_deref(), Some("example.com"));
+ let record = &state.sessions[&session];
+ assert_eq!((record.calls, record.errors, record.ended), (2, 1, true));
+ let kinds: Vec = state
+ .thread
+ .iter()
+ .filter(|item| item.session_id.as_deref() == Some(&session))
+ .map(|item| item.kind)
+ .collect();
+ assert_eq!(kinds, vec![ThreadKind::Joined, ThreadKind::Left]);
+ }
+}
diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs
index 32bc68b1..7e40b3d1 100644
--- a/src-tauri/src/api_server.rs
+++ b/src-tauri/src/api_server.rs
@@ -1437,6 +1437,11 @@ async fn get_profile(
/// `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other
/// OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise.
/// A `wayfern_config` that fails to parse is a 400, never a silent default.
+/// - `wayfern_config.launch_args` accepts up to 64 `--name` or
+/// `--name=value` strings on plans with browser automation (402 otherwise).
+/// Each string is one argument. Only supported switches are accepted (for
+/// example `--disable-gpu`, `--mute-audio`, `--disable-features=Name`);
+/// anything else, including the switches Donut manages, is refused with 400.
/// - Pro accounts may generate 100 profiles per rolling hour; the 101st is
/// refused with 429 and a `retryAfterSeconds` in the body.
#[utoipa::path(
@@ -1447,7 +1452,7 @@ async fn get_profile(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
- (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"),
+ (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint or custom launch arguments require Pro"),
(status = 429, description = "Hourly profile generation limit reached; the body carries retryAfterSeconds"),
(status = 500, description = "Internal server error")
),
@@ -5172,6 +5177,46 @@ mod tests {
assert_eq!(config.os.as_deref(), Some("android"));
}
+ #[test]
+ fn openapi_documents_launch_args_and_their_plan_requirement() {
+ let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
+ let operation = &spec["paths"]["/v1/profiles"]["post"];
+ assert!(operation["description"]
+ .as_str()
+ .unwrap()
+ .contains("launch_args"));
+ assert!(operation["responses"]["402"]["description"]
+ .as_str()
+ .unwrap()
+ .contains("launch arguments"));
+ for (code, expected) in [
+ (
+ "WAYFERN_LAUNCH_ARGS_REQUIRES_PRO",
+ StatusCode::PAYMENT_REQUIRED,
+ ),
+ ("WAYFERN_LAUNCH_ARG_INVALID", StatusCode::BAD_REQUEST),
+ ("WAYFERN_LAUNCH_ARG_RESERVED", StatusCode::BAD_REQUEST),
+ ("WAYFERN_LAUNCH_ARG_NOT_ALLOWED", StatusCode::BAD_REQUEST),
+ ("WAYFERN_LAUNCH_ARG_VALUE_INVALID", StatusCode::BAD_REQUEST),
+ ("WAYFERN_LAUNCH_ARGS_LIMIT", StatusCode::BAD_REQUEST),
+ ] {
+ assert_eq!(
+ manager_error_response(crate::backend_error(code)).0,
+ expected
+ );
+ }
+
+ let request: CreateProfileRequest = serde_json::from_value(serde_json::json!({
+ "name": "With launch arguments",
+ "browser": "wayfern",
+ "wayfern_config": { "launch_args": ["--disable-gpu"] }
+ }))
+ .unwrap();
+ let config: crate::wayfern_manager::WayfernConfig =
+ serde_json::from_value(request.wayfern_config.unwrap()).unwrap();
+ assert_eq!(config.launch_args, vec!["--disable-gpu"]);
+ }
+
#[test]
fn malformed_wayfern_config_is_an_error_not_a_default() {
// `fingerprint` is a JSON-encoded string, so passing an object fails to
diff --git a/src-tauri/src/automation_rate_limiter.rs b/src-tauri/src/automation_rate_limiter.rs
index 766a346f..cf657a2a 100644
--- a/src-tauri/src/automation_rate_limiter.rs
+++ b/src-tauri/src/automation_rate_limiter.rs
@@ -13,6 +13,14 @@ pub enum RateLimitOutcome {
Limited { retry_after_secs: u64 },
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
+pub struct AutomationQuota {
+ /// 0 means unlimited.
+ pub limit: u64,
+ pub used: u64,
+ pub resets_in_secs: Option,
+}
+
#[derive(Default)]
struct AutomationRateLimiter {
requests: HashMap>,
@@ -56,6 +64,25 @@ impl AutomationRateLimiter {
}
}
+impl AutomationRateLimiter {
+ fn usage_at(&self, identity: &str, now: Instant) -> (u64, Option) {
+ let Some(requests) = self.requests.get(identity) else {
+ return (0, None);
+ };
+ let mut live = requests
+ .iter()
+ .filter(|started| now.duration_since(**started) < RATE_LIMIT_WINDOW);
+ let resets_in_secs = live.clone().next().map(|started| {
+ RATE_LIMIT_WINDOW
+ .saturating_sub(now.duration_since(*started))
+ .as_secs()
+ .max(1)
+ });
+ let used = live.by_ref().count() as u64;
+ (used, resets_in_secs)
+ }
+}
+
static AUTOMATION_RATE_LIMITER: LazyLock> =
LazyLock::new(|| Mutex::new(AutomationRateLimiter::default()));
@@ -70,10 +97,40 @@ pub async fn check_automation_rate_limit() -> RateLimitOutcome {
.check_at(&identity, requests_per_hour, Instant::now())
}
+pub async fn automation_quota() -> Option {
+ let (identity, limit) = CLOUD_AUTH.automation_rate_limit().await?;
+ let (used, resets_in_secs) = AUTOMATION_RATE_LIMITER
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .usage_at(&identity, Instant::now());
+ Some(AutomationQuota {
+ limit,
+ used,
+ resets_in_secs,
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn usage_counts_only_the_live_window_without_consuming() {
+ let mut limiter = AutomationRateLimiter::default();
+ let start = Instant::now();
+ assert_eq!(limiter.usage_at("a", start), (0, None));
+ limiter.check_at("a", 5, start);
+ limiter.check_at("a", 5, start + Duration::from_secs(10));
+ let (used, resets) = limiter.usage_at("a", start + Duration::from_secs(20));
+ assert_eq!(used, 2);
+ assert_eq!(resets, Some(RATE_LIMIT_WINDOW.as_secs() - 20));
+ assert_eq!(limiter.usage_at("a", start + Duration::from_secs(20)).0, 2);
+ assert_eq!(
+ limiter.usage_at("a", start + RATE_LIMIT_WINDOW + Duration::from_secs(11)),
+ (0, None)
+ );
+ }
+
#[test]
fn rolling_window_limits_per_identity_and_recovers() {
let mut limiter = AutomationRateLimiter::default();
diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs
index 54da792a..3c789a92 100644
--- a/src-tauri/src/cloud_auth.rs
+++ b/src-tauri/src/cloud_auth.rs
@@ -110,15 +110,6 @@ pub struct Entitlements {
/// cycle out of date must not be what refuses a customer their own machine.
#[serde(rename = "remoteControl", default)]
pub remote_control: bool,
- /// Whether the plan may run the browsing agent: a goal the cloud pursues on
- /// one profile, on this desktop or on a leased host.
- ///
- /// Read only by the UI, and never back-filled from `browser_automation`. A
- /// backend too old to send this key is a backend with no `api/agent` routes
- /// to be entitled to, so `false` is the true answer rather than a gap to
- /// guess at — the same reasoning `remote_control` is held to.
- #[serde(rename = "agentAutomation", default)]
- pub agent_automation: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
@@ -149,22 +140,21 @@ fn derive_entitlements(
cookie_bot: false,
remote_interactive: false,
remote_control: false,
- agent_automation: false,
profile_limit: 0,
requests_per_hour: 0,
remote_browser_hours: 0,
};
}
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
- // team_collaboration, cookie_bot, remote_interactive, remote_control,
- // agent_automation).
+ // team_collaboration, cookie_bot, remote_interactive, remote_control).
//
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
// one row where cookie_bot and browser_automation disagree, which is why
// cookie_bot can no longer be derived from browser_automation below.
//
- // remote_control belongs to pro, team and enterprise, and is withheld from
- // the unrecognized row rather than granted with the rest. Everything else here defaults
+ // remote_control belongs to solo (MCP without browser automation), pro,
+ // team and enterprise, and is withheld from the unrecognized row rather
+ // than granted with the rest. Everything else here defaults
// generous so a comped account is never locked out of what it is paying for;
// an internet-facing hook into this machine is the one capability where
// guessing "probably yes" is not the safe direction to guess in.
@@ -176,13 +166,12 @@ fn derive_entitlements(
cookie_bot,
remote_interactive,
remote_control,
- agent_automation,
) = match plan {
- "solo" => (false, false, true, false, true, false, false, false),
- "enterprise" => (true, true, true, true, true, true, true, true),
- "team" => (true, true, true, true, true, true, true, true),
- "pro" => (true, true, true, false, true, true, true, true),
- _ => (true, true, true, false, true, true, false, true),
+ "solo" => (false, false, true, false, true, false, true),
+ "enterprise" => (true, true, true, true, true, true, true),
+ "team" => (true, true, true, true, true, true, true),
+ "pro" => (true, true, true, false, true, true, true),
+ _ => (true, true, true, false, true, true, false),
};
Entitlements {
active,
@@ -193,7 +182,6 @@ fn derive_entitlements(
cookie_bot,
remote_interactive,
remote_control,
- agent_automation,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
@@ -852,7 +840,7 @@ impl CloudAuthManager {
crate::team_lock::PROFILE_LOCK.disconnect().await;
sync::stop_subscription();
crate::remote_session::stop_session_events();
- crate::agent::stop_run_events();
+ crate::agent_console::reset();
let _ = crate::events::emit_empty("cloud-auth-expired");
}
@@ -1139,6 +1127,7 @@ impl CloudAuthManager {
// an "unauthorized" the account page shows to somebody who has simply
// signed out, and it would hold the account's bridge slot meanwhile.
crate::mcp_remote::stop(None);
+ crate::agent_console::reset();
// Before the session is closed server-side and the tokens are deleted:
// both of those take away the only thing that can revoke it.
@@ -2158,42 +2147,19 @@ mod tests {
}
#[test]
- fn the_agent_follows_browser_automation_and_is_never_derived_from_it() {
- // Solo funds a nightly bot and nothing that drives a browser by hand, so
- // it does not get the agent either.
- assert!(!active_solo().agent_automation);
- for plan in ["pro", "team", "enterprise", "some-comped-plan"] {
- let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
- assert!(derived.agent_automation, "{plan} should get the agent");
- }
- // An inactive subscription buys nothing, whatever the plan says.
- assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).agent_automation);
- }
-
- #[test]
- fn remote_control_is_granted_to_pro_team_and_enterprise_only() {
- for plan in ["pro", "team", "enterprise"] {
+ fn remote_control_is_granted_to_every_sold_plan_but_not_an_unknown_one() {
+ for plan in ["solo", "pro", "team", "enterprise"] {
let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
assert!(derived.remote_control, "{plan} should get remote control");
}
- assert!(!active_solo().remote_control);
+ assert!(
+ !active_solo().browser_automation,
+ "solo gets MCP without browser automation"
+ );
assert!(!derive_entitlements("some-comped-plan", Some("monthly"), "active", 50).remote_control);
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).remote_control);
}
- #[test]
- fn a_backend_that_never_heard_of_the_agent_reports_no_agent() {
- // The whole point of `default` here: an older backend's entitlements object
- // must decode, and the missing key must read as "no agent routes exist"
- // rather than being back-filled from browser automation.
- let older: Entitlements = serde_json::from_str(
- r#"{"active":true,"browserAutomation":true,"cloudBackup":true,"profileLimit":50}"#,
- )
- .unwrap();
- assert!(older.active && older.browser_automation);
- assert!(!older.agent_automation);
- }
-
#[test]
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
// The regression this guards: gating the mint on `active` asked for a token
@@ -2526,7 +2492,6 @@ mod tests {
"crate::team_lock::PROFILE_LOCK.disconnect()",
"sync::stop_subscription()",
"crate::remote_session::stop_session_events()",
- "crate::agent::stop_run_events()",
"crate::mcp_remote::stop(None)",
] {
assert!(body.contains(stop), "invalidate_session must call {stop}");
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 966daa4c..e0f774b8 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -93,6 +93,7 @@ pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
pub mod proxy_udp;
+mod recipes;
mod recorder;
mod remote_exit;
mod remote_handoff;
@@ -110,7 +111,7 @@ mod wayfern_persona;
mod wayfern_terms;
mod window_decorations;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
-mod agent;
+mod agent_console;
pub mod cloud_auth;
mod cloud_errors;
mod commercial_license;
@@ -3621,20 +3622,19 @@ pub fn run_with_builder(
cookie_bot::create_cookie_bot_user_template,
cookie_bot::update_cookie_bot_user_template,
cookie_bot::delete_cookie_bot_user_template,
- // Agent commands. Defined in `agent.rs` for the same reason: every local
- // precondition they have (the profile is on this machine, the goal says
- // something) lives beside the transport that sends them.
- agent::start_agent_run,
- agent::get_agent_runs,
- agent::get_agent_run,
- agent::cancel_agent_run,
- agent::get_agent_recipes,
- agent::create_agent_recipe,
- agent::update_agent_recipe,
- agent::delete_agent_recipe,
- agent::start_agent_run_events,
- agent::stop_agent_run_events,
- agent::get_agent_run_events_status,
+ recipes::get_agent_recipes,
+ recipes::create_agent_recipe,
+ recipes::update_agent_recipe,
+ recipes::delete_agent_recipe,
+ agent_console::get_agent_console,
+ agent_console::answer_agent_request,
+ agent_console::dismiss_agent_request,
+ agent_console::send_agent_note,
+ agent_console::take_over_profile,
+ agent_console::hand_back_profile,
+ agent_console::set_agents_paused,
+ agent_console::clear_agent_activity,
+ agent_console::show_profile_window,
// Profile password commands
set_profile_password,
change_profile_password,
@@ -3651,9 +3651,6 @@ pub fn run_with_builder(
// never waits out a reconnect backoff that is about to be pointless.
if let tauri::RunEvent::Exit = _event {
remote_session::stop_session_events();
- // The agent step stream is the same shape of subscriber and would hold
- // a shutdown for the length of its reconnect backoff.
- agent::stop_run_events();
// Same reasoning for the remote-control bridge, plus one of its own:
// one account holds one bridge at a time, so an instance that exits
// without hanging up delays the account's next machine.
diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs
index 96a2606a..bff11bd7 100644
--- a/src-tauri/src/mcp_server.rs
+++ b/src-tauri/src/mcp_server.rs
@@ -134,6 +134,10 @@ pub struct McpRequest {
const PROTOCOL_VERSION: &str = "2025-11-25";
+const INITIALIZE_INSTRUCTIONS: &str = "Donut Browser MCP server. Use tools/list to discover the browser tools. A person watches your work in Donut: call get_human_updates when you start and between steps, report_progress during longer jobs, ask_human when you are unsure, and request_human_help when a page needs a person. A call refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN means the person is in control; do not retry it until get_human_updates shows the pause or hold is gone.";
+
+const PLAN_WITHOUT_AUTOMATION: &str = " This account's plan does not include browser automation: tools that launch, drive or read a browser are refused with PLAN_REQUIRED. Managing profiles, groups, proxies, VPNs and extensions works.";
+
/// Every MCP protocol revision this engine can speak, newest first.
///
/// The spec says a server MUST echo the client's requested version when it
@@ -2214,8 +2218,8 @@ impl McpServer {
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
return Err(McpError {
code: -32000,
- message: format!("{feature} requires a plan that includes this feature"),
- data: None,
+ message: format!("{feature} is not included in this account's plan"),
+ data: Some(serde_json::json!({ "code": "PLAN_REQUIRED", "feature": feature })),
});
}
Ok(())
@@ -2496,6 +2500,7 @@ impl McpServer {
match inner.sessions.remove(session_id) {
Some(session) => {
log::info!("[mcp] Session terminated: {}", ShortId(session_id));
+ crate::agent_console::session_ended(session_id);
session.cached_pages
}
None => return,
@@ -2668,7 +2673,7 @@ impl McpServer {
}
}
- if Self::is_automation_tool_call(&request) {
+ if Self::is_automation_tool_call(&request) && !Self::refused_by_console(&request) {
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
crate::automation_rate_limiter::check_automation_rate_limit().await
{
@@ -2777,6 +2782,24 @@ impl McpServer {
return false;
};
+ Self::is_automation_tool(tool_name)
+ }
+
+ /// A call the person has paused or taken over does not count against the quota.
+ fn refused_by_console(request: &McpRequest) -> bool {
+ let Some(params) = request.params.as_ref() else {
+ return false;
+ };
+ let tool_name = params
+ .get("name")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ let empty = serde_json::json!({});
+ let arguments = params.get("arguments").unwrap_or(&empty);
+ crate::agent_console::check_call(Self::is_passive_tool(tool_name), arguments).is_err()
+ }
+
+ fn is_automation_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"run_profile"
@@ -4546,6 +4569,68 @@ impl McpServer {
"required": ["profile_id"]
}),
},
+ McpTool {
+ name: "ask_human".to_string(),
+ description: "Ask the person at this computer a question and wait for the answer. Use it when you are unsure what they want, need a value only they know (a code, an account choice), or before a step that cannot be undone. Offer choices when you can. Returns {status: \"answered\", answer}, or {status: \"pending\", request_id} when they have not answered within wait_seconds; then call wait_for_human.".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "question": { "type": "string", "description": "One short, clear question" },
+ "choices": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Optional answers to pick from (at most 8)"
+ },
+ "profile_id": { "type": "string", "description": "The profile the question is about, if any" },
+ "wait_seconds": { "type": "integer", "description": "How long to wait for the answer (default 45, max 55)" }
+ },
+ "required": ["question"]
+ }),
+ },
+ McpTool {
+ name: "request_human_help".to_string(),
+ description: "Hand a profile to the person at this computer when its page needs a human: a sign-in, a verification challenge, a payment, or anything you cannot do. The profile is held for them, and your calls on it are refused with PROFILE_HELD_BY_HUMAN until they hand it back. Returns {status: \"done\", note} when they hand it back, or {status: \"pending\", request_id}; then call wait_for_human, or work on other profiles meanwhile.".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "profile_id": { "type": "string", "description": "The UUID of the profile that needs the person" },
+ "reason": { "type": "string", "description": "What the person should do, in one sentence" },
+ "wait_seconds": { "type": "integer", "description": "How long to wait (default 45, max 55)" }
+ },
+ "required": ["profile_id", "reason"]
+ }),
+ },
+ McpTool {
+ name: "wait_for_human".to_string(),
+ description: "Keep waiting for the outcome of an ask_human or request_human_help request. Returns status answered, done, dismissed, or pending (call again).".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "request_id": { "type": "integer", "description": "The request_id from ask_human or request_human_help" },
+ "wait_seconds": { "type": "integer", "description": "How long to wait (default 45, max 55)" }
+ },
+ "required": ["request_id"]
+ }),
+ },
+ McpTool {
+ name: "get_human_updates".to_string(),
+ description: "Read what the person at this computer wants you to know: their notes to you, the profiles they have taken over, whether agents are paused, and outcomes of your earlier requests. Call it when you start, between steps of a long job, and whenever a tool is refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN.".to_string(),
+ input_schema: serde_json::json!({ "type": "object", "properties": {} }),
+ },
+ McpTool {
+ name: "report_progress".to_string(),
+ description: "Tell the person what you are doing, in one short sentence, with done and total counts for a job over many items. It is shown next to your name in Donut. Call it when you start a job and every few items.".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "message": { "type": "string", "description": "What you are doing now (at most 280 characters)" },
+ "done": { "type": "integer", "description": "Items finished so far" },
+ "total": { "type": "integer", "description": "Items in the whole job" },
+ "profile_id": { "type": "string", "description": "The profile you are working on, if one" }
+ },
+ "required": ["message"]
+ }),
+ },
]
}
@@ -4574,8 +4659,23 @@ impl McpServer {
.and_then(serde_json::Value::as_str),
);
+ let client_info = request
+ .params
+ .as_ref()
+ .and_then(|params| params.get("clientInfo"));
+ let client_field = |key: &str| {
+ client_info
+ .and_then(|info| info.get(key))
+ .and_then(serde_json::Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ };
+ let (client_name, client_version) = (client_field("name"), client_field("version"));
+
// Create session
let session_id = Uuid::new_v4().to_string();
+ let mut evicted = Vec::new();
{
let mut inner = self.inner.lock().await;
inner.sessions.insert(
@@ -4606,9 +4706,18 @@ impl McpServer {
};
log::warn!("[mcp] Session cap reached; evicting the oldest session");
inner.sessions.remove(&oldest);
+ evicted.push(oldest);
}
}
+ crate::agent_console::session_started(&session_id, client_name, client_version);
+ for oldest in evicted {
+ crate::agent_console::session_ended(&oldest);
+ }
+ let mut instructions = INITIALIZE_INSTRUCTIONS.to_string();
+ if !CLOUD_AUTH.can_use_browser_automation().await {
+ instructions.push_str(PLAN_WITHOUT_AUTOMATION);
+ }
let result = serde_json::json!({
"protocolVersion": negotiated,
"capabilities": {
@@ -4620,7 +4729,7 @@ impl McpServer {
"name": SERVER_NAME,
"version": SERVER_VERSION,
},
- "instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools."
+ "instructions": instructions
});
log::info!("[mcp] New session initialized: {}", ShortId(&session_id));
@@ -4728,11 +4837,44 @@ impl McpServer {
});
}
- let result = self
+ let human_tool = crate::agent_console::HUMAN_TOOLS.contains(&tool_name);
+ if let Err(refusal) =
+ crate::agent_console::check_call(Self::is_passive_tool(tool_name), &arguments)
+ {
+ let error = Self::console_refusal(refusal);
+ crate::agent_console::record_call(crate::agent_console::CallRecord {
+ session_id: caller.session,
+ tool: tool_name,
+ arguments: &arguments,
+ error_code: Some(Self::error_code_of(&error)),
+ duration_ms: 0,
+ })
+ .await;
+ return Err(error);
+ }
+
+ let mut result = self
.dispatch_tool_call(caller, tool_name, &arguments)
.await
.map_err(|error| explain_engine_refusal(error, CLOUD_AUTH.wayfern_device_refusal()));
let elapsed_ms = started.elapsed().as_millis();
+ if human_tool {
+ crate::agent_console::touch_session(caller.session);
+ } else {
+ crate::agent_console::record_call(crate::agent_console::CallRecord {
+ session_id: caller.session,
+ tool: tool_name,
+ arguments: &arguments,
+ error_code: result.as_ref().err().map(Self::error_code_of),
+ duration_ms: elapsed_ms as u64,
+ })
+ .await;
+ }
+ if tool_name != "get_human_updates" {
+ if let Ok(value) = result.as_mut() {
+ Self::attach_notes(value, caller.session);
+ }
+ }
match &result {
Ok(_) => {
log::info!(
@@ -4757,6 +4899,13 @@ impl McpServer {
arguments: &serde_json::Value,
) -> Result {
match tool_name {
+ "ask_human" => self.handle_ask_human(caller, arguments).await,
+ "request_human_help" => self.handle_request_human_help(caller, arguments).await,
+ "wait_for_human" => self.handle_wait_for_human(arguments).await,
+ "get_human_updates" => {
+ Self::json_content(&crate::agent_console::human_updates(caller.session))
+ }
+ "report_progress" => Self::handle_report_progress(caller, arguments),
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
@@ -5682,7 +5831,12 @@ impl McpServer {
};
let pm = ProfileManager::instance();
- if let Some(new_name) = arguments.get("name").and_then(|v| v.as_str()) {
+ // Agents often send every optional field; an empty name means "unchanged".
+ if let Some(new_name) = arguments
+ .get("name")
+ .and_then(|v| v.as_str())
+ .filter(|name| !name.trim().is_empty())
+ {
pm.rename_profile(&app_handle, profile_id, new_name)
.map_err(|e| McpError {
code: -32000,
@@ -9127,6 +9281,241 @@ impl McpServer {
Self::json_content(&extraction)
}
+ /// Reads and the human channel keep working while agents are paused.
+ fn is_passive_tool(tool_name: &str) -> bool {
+ crate::agent_console::HUMAN_TOOLS.contains(&tool_name)
+ || ((tool_name.starts_with("list_") || tool_name.starts_with("get_"))
+ && !Self::is_automation_tool(tool_name))
+ }
+
+ fn error_code_of(error: &McpError) -> String {
+ if let Some(code) = error
+ .data
+ .as_ref()
+ .and_then(|data| data.get("code"))
+ .and_then(serde_json::Value::as_str)
+ {
+ return code.to_string();
+ }
+ if let Some(code) = serde_json::from_str::(&error.message)
+ .ok()
+ .and_then(|value| {
+ value
+ .get("code")
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string)
+ })
+ {
+ return code;
+ }
+ match error.code {
+ -32602 => "INVALID_ARGUMENT".to_string(),
+ _ => "TOOL_ERROR".to_string(),
+ }
+ }
+
+ fn console_refusal(refusal: crate::agent_console::Refusal) -> McpError {
+ match refusal {
+ crate::agent_console::Refusal::Paused { note } => McpError {
+ code: -32000,
+ message: match ¬e {
+ Some(note) => format!("The person at this computer paused all agents: {note}. Wait, then call get_human_updates before you continue."),
+ None => "The person at this computer paused all agents. Wait, then call get_human_updates before you continue.".to_string(),
+ },
+ data: Some(serde_json::json!({ "code": "AGENT_PAUSED", "note": note })),
+ },
+ crate::agent_console::Refusal::ProfileHeld { profile_id, note } => McpError {
+ code: -32000,
+ message: match ¬e {
+ Some(note) => format!("The person at this computer has taken over profile {profile_id}: {note}. Work on other profiles; get_human_updates shows when it is handed back."),
+ None => format!("The person at this computer has taken over profile {profile_id}. Work on other profiles; get_human_updates shows when it is handed back."),
+ },
+ data: Some(serde_json::json!({ "code": "PROFILE_HELD_BY_HUMAN", "profileId": profile_id, "note": note })),
+ },
+ }
+ }
+
+ fn attach_notes(value: &mut serde_json::Value, session: Option<&str>) {
+ let Some(content) = value
+ .get_mut("content")
+ .and_then(serde_json::Value::as_array_mut)
+ else {
+ return;
+ };
+ for note in crate::agent_console::take_notes(session) {
+ content.push(serde_json::json!({
+ "type": "text",
+ "text": crate::agent_console::note_text(¬e),
+ }));
+ }
+ }
+
+ fn wait_seconds(arguments: &serde_json::Value) -> u64 {
+ arguments
+ .get("wait_seconds")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(crate::agent_console::DEFAULT_WAIT_SECS)
+ .min(crate::agent_console::MAX_WAIT_SECS)
+ }
+
+ fn invalid_argument(message: impl Into) -> McpError {
+ McpError {
+ code: -32602,
+ message: message.into(),
+ data: Some(serde_json::json!({ "code": "INVALID_ARGUMENT" })),
+ }
+ }
+
+ fn optional_profile(&self, arguments: &serde_json::Value) -> Result, McpError> {
+ let Some(profile_id) = arguments
+ .get("profile_id")
+ .and_then(serde_json::Value::as_str)
+ .map(str::trim)
+ .filter(|id| !id.is_empty())
+ else {
+ return Ok(None);
+ };
+ let known = crate::profile::manager::ProfileManager::instance()
+ .list_profiles()
+ .map(|profiles| profiles.iter().any(|p| p.id.to_string() == profile_id))
+ .unwrap_or(false);
+ if !known {
+ return Err(Self::invalid_argument(format!(
+ "Unknown profile_id {profile_id}"
+ )));
+ }
+ Ok(Some(profile_id.to_string()))
+ }
+
+ async fn handle_ask_human(
+ &self,
+ caller: McpCaller<'_>,
+ arguments: &serde_json::Value,
+ ) -> Result {
+ let question = arguments
+ .get("question")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ let choices: Vec = arguments
+ .get("choices")
+ .and_then(serde_json::Value::as_array)
+ .map(|list| {
+ list
+ .iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+ let (question, choices) = crate::agent_console::validate_question(question, &choices)
+ .map_err(Self::invalid_argument)?;
+ let profile_id = self.optional_profile(arguments)?;
+ let request_id = crate::agent_console::open_request(crate::agent_console::NewRequest {
+ session_id: caller.session.map(str::to_string),
+ kind: crate::agent_console::ThreadKind::Question,
+ text: question,
+ profile_id,
+ choices,
+ })
+ .await;
+ let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
+ .await
+ .unwrap_or(crate::agent_console::RequestOutcome::Pending);
+ Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
+ }
+
+ async fn handle_request_human_help(
+ &self,
+ caller: McpCaller<'_>,
+ arguments: &serde_json::Value,
+ ) -> Result {
+ let profile_id = self
+ .optional_profile(arguments)?
+ .ok_or_else(|| Self::invalid_argument("profile_id is required"))?;
+ let reason = arguments
+ .get("reason")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ let (reason, _) = crate::agent_console::validate_question(reason, &[]).map_err(|_| {
+ Self::invalid_argument(format!(
+ "reason must be 1 to {} characters",
+ crate::agent_console::MAX_TEXT_CHARS
+ ))
+ })?;
+ let request_id = crate::agent_console::open_request(crate::agent_console::NewRequest {
+ session_id: caller.session.map(str::to_string),
+ kind: crate::agent_console::ThreadKind::Help,
+ text: reason,
+ profile_id: Some(profile_id),
+ choices: Vec::new(),
+ })
+ .await;
+ let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
+ .await
+ .unwrap_or(crate::agent_console::RequestOutcome::Pending);
+ Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
+ }
+
+ async fn handle_wait_for_human(
+ &self,
+ arguments: &serde_json::Value,
+ ) -> Result {
+ let request_id = arguments
+ .get("request_id")
+ .and_then(serde_json::Value::as_u64)
+ .ok_or_else(|| Self::invalid_argument("request_id is required"))?;
+ if !crate::agent_console::request_exists(request_id) {
+ return Err(McpError {
+ code: -32602,
+ message: format!("No request {request_id}; it may be from before Donut restarted"),
+ data: Some(serde_json::json!({ "code": "AGENT_REQUEST_NOT_FOUND" })),
+ });
+ }
+ let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
+ .await
+ .unwrap_or(crate::agent_console::RequestOutcome::Pending);
+ Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
+ }
+
+ fn handle_report_progress(
+ caller: McpCaller<'_>,
+ arguments: &serde_json::Value,
+ ) -> Result {
+ let message = arguments
+ .get("message")
+ .and_then(serde_json::Value::as_str)
+ .map(str::trim)
+ .filter(|message| !message.is_empty())
+ .ok_or_else(|| Self::invalid_argument("message is required"))?;
+ let done = arguments.get("done").and_then(serde_json::Value::as_u64);
+ let total = arguments.get("total").and_then(serde_json::Value::as_u64);
+ let profile_id = arguments
+ .get("profile_id")
+ .and_then(serde_json::Value::as_str)
+ .map(str::trim)
+ .filter(|id| !id.is_empty())
+ .map(str::to_string);
+ crate::agent_console::report_progress(caller.session, message, done, total, profile_id);
+ Self::json_content(&serde_json::json!({ "ok": true }))
+ }
+
+ pub(crate) async fn bring_profile_to_front(&self, profile_id: &str) -> Result<(), String> {
+ let target = self
+ .resolve_cdp_target(profile_id)
+ .await
+ .map_err(|_| crate::backend_error("PROFILE_NOT_RUNNING"))?;
+ self
+ .send_cdp(&target, "Page.bringToFront", serde_json::json!({}))
+ .await
+ .map(|_| ())
+ .map_err(|e| {
+ log::warn!(
+ "[mcp] Could not bring profile {profile_id} to front: {}",
+ e.message
+ );
+ crate::backend_error("PROFILE_NOT_RUNNING")
+ })
+ }
+
async fn handle_pick_element(
&self,
caller: McpCaller<'_>,
@@ -9823,7 +10212,7 @@ mod tests {
// tool was added or removed on purpose.
assert_eq!(
tools.len(),
- 87,
+ 92,
"the tool list is a published contract; update this number deliberately"
);
@@ -12327,4 +12716,144 @@ mod tests {
assert_eq!(canonical.attributes, full.attributes);
assert!(canonical_locator(&LocatorDescription::default()).is_empty());
}
+
+ #[tokio::test]
+ async fn an_agent_and_the_person_can_talk_through_the_bridge() {
+ let server = McpServer::new();
+ server.mark_engine_ready_for_tests();
+ let call = |session: Option, body: serde_json::Value| {
+ let server = &server;
+ async move {
+ let bytes = body.to_string();
+ match server
+ .handle_message(McpOrigin::Bridge, session.as_deref(), bytes.as_bytes())
+ .await
+ {
+ McpOutcome::Body {
+ body,
+ new_session_id,
+ } => (body, new_session_id),
+ _ => panic!("expected an answer"),
+ }
+ }
+ };
+ let tool = |id: u64, name: &str, arguments: serde_json::Value| {
+ serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": "tools/call",
+ "params": { "name": name, "arguments": arguments }
+ })
+ };
+ let text = |body: &serde_json::Value, index: usize| -> serde_json::Value {
+ serde_json::from_str(
+ body["result"]["content"][index]["text"]
+ .as_str()
+ .unwrap_or("null"),
+ )
+ .unwrap_or(serde_json::Value::Null)
+ };
+
+ let (init, session) = call(
+ None,
+ serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": { "protocolVersion": "2025-06-18", "clientInfo": { "name": "Claude Code", "version": "2.1.0" } }
+ }),
+ )
+ .await;
+ let session = session.expect("initialize opens a session");
+ assert!(init["result"]["instructions"]
+ .as_str()
+ .unwrap()
+ .contains("get_human_updates"));
+ let recorded = crate::agent_console::snapshot()
+ .await
+ .sessions
+ .into_iter()
+ .find(|s| s.session_id == session)
+ .expect("the console knows the session");
+ assert_eq!(recorded.client_name.as_deref(), Some("Claude Code"));
+
+ let (asked, _) = call(
+ Some(session.clone()),
+ tool(2, "ask_human", serde_json::json!({ "question": "Which account?", "choices": ["A", "B"], "wait_seconds": 0 })),
+ )
+ .await;
+ let pending = text(&asked, 0);
+ assert_eq!(pending["status"], "pending", "{asked}");
+ let request_id = pending["request_id"].as_u64().unwrap();
+ crate::agent_console::answer_agent_request(request_id, "B".to_string())
+ .await
+ .unwrap();
+ let (waited, _) = call(
+ Some(session.clone()),
+ tool(
+ 3,
+ "wait_for_human",
+ serde_json::json!({ "request_id": request_id, "wait_seconds": 0 }),
+ ),
+ )
+ .await;
+ assert_eq!(text(&waited, 0)["answer"], "B");
+
+ crate::agent_console::send_agent_note(
+ "Skip profile 7".to_string(),
+ Some(session.clone()),
+ None,
+ )
+ .await
+ .unwrap();
+ let (progress, _) = call(
+ Some(session.clone()),
+ tool(
+ 4,
+ "report_progress",
+ serde_json::json!({ "message": "Checking", "done": 1, "total": 3 }),
+ ),
+ )
+ .await;
+ let note = progress["result"]["content"][1]["text"]
+ .as_str()
+ .unwrap_or_default();
+ assert!(note.contains("Skip profile 7"), "{progress}");
+
+ let held = format!("held-{}", Uuid::new_v4());
+ let help = crate::agent_console::open_request(crate::agent_console::NewRequest {
+ session_id: Some(session.clone()),
+ kind: crate::agent_console::ThreadKind::Help,
+ text: "Sign in".to_string(),
+ profile_id: Some(held.clone()),
+ choices: Vec::new(),
+ })
+ .await;
+ let (refused, _) = call(
+ Some(session.clone()),
+ tool(
+ 5,
+ "navigate",
+ serde_json::json!({ "profile_id": held, "url": "https://example.com" }),
+ ),
+ )
+ .await;
+ assert_eq!(
+ refused["error"]["data"]["code"], "PROFILE_HELD_BY_HUMAN",
+ "{refused}"
+ );
+ let (updates, _) = call(
+ Some(session.clone()),
+ tool(6, "get_human_updates", serde_json::json!({})),
+ )
+ .await;
+ assert!(text(&updates, 0)["held_profiles"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|hold| hold["profile_id"] == held.as_str()));
+ crate::agent_console::dismiss_agent_request(help)
+ .await
+ .unwrap();
+ }
}
diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs
index d0adc711..0d74b13c 100644
--- a/src-tauri/src/profile/manager.rs
+++ b/src-tauri/src/profile/manager.rs
@@ -159,7 +159,7 @@ impl ProfileManager {
release_type: &str,
proxy_id: Option,
vpn_id: Option,
- wayfern_config: Option,
+ mut wayfern_config: Option,
group_id: Option,
ephemeral: bool,
dns_blocklist: Option,
@@ -185,6 +185,10 @@ impl ProfileManager {
let launch_hook = Self::normalize_launch_hook(launch_hook)?;
+ if let Some(config) = wayfern_config.as_mut() {
+ config.launch_args = config.checked_launch_args().await?;
+ }
+
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
@@ -209,7 +213,11 @@ impl ProfileManager {
.iter()
.any(|p| p.name.to_lowercase() == name.to_lowercase())
{
- return Err(format!("Profile with name '{name}' already exists").into());
+ return Err(
+ serde_json::json!({ "code": "PROFILE_NAME_EXISTS", "params": { "name": name } })
+ .to_string()
+ .into(),
+ );
}
// Generate a new UUID for this profile
@@ -599,7 +607,8 @@ impl ProfileManager {
profile_id: &str,
new_name: &str,
) -> Result> {
- if new_name.trim().is_empty() {
+ let new_name = new_name.trim();
+ if new_name.is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
@@ -607,22 +616,28 @@ impl ProfileManager {
);
}
- // Check if new name already exists (case insensitive)
let existing_profiles = self.list_profiles()?;
- if existing_profiles
- .iter()
- .any(|p| p.name.to_lowercase() == new_name.to_lowercase())
- {
- return Err(format!("Profile with name '{new_name}' already exists").into());
- }
-
- // Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
+ // Case-insensitive, but a profile never collides with itself.
+ if existing_profiles
+ .iter()
+ .any(|p| p.id != profile_uuid && p.name.to_lowercase() == new_name.to_lowercase())
+ {
+ return Err(
+ serde_json::json!({ "code": "PROFILE_NAME_EXISTS", "params": { "name": new_name } })
+ .to_string()
+ .into(),
+ );
+ }
+
let mut profile = existing_profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
+ if profile.name == new_name {
+ return Ok(profile);
+ }
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
@@ -1516,25 +1531,26 @@ impl ProfileManager {
&self,
app_handle: tauri::AppHandle,
profile_id: &str,
- config: WayfernConfig,
+ mut config: WayfernConfig,
) -> Result<(), Box> {
+ config.launch_args = config.checked_launch_args().await?;
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box {
- format!("Invalid profile ID: {profile_id}").into()
+ crate::backend_error("INVALID_PROFILE_ID").into()
},
)?;
let profiles =
self
.list_profiles()
.map_err(|e| -> Box {
- format!("Failed to list profiles: {e}").into()
+ crate::backend_error_with_detail("INTERNAL_ERROR", e).into()
})?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box {
- format!("Profile with ID '{profile_id}' not found").into()
+ crate::backend_error("PROFILE_NOT_FOUND").into()
})?;
// Check if the browser is currently running using the comprehensive status check
@@ -1543,21 +1559,45 @@ impl ProfileManager {
.await?;
if is_running {
- return Err(
- "Cannot update Wayfern configuration while browser is running. Please stop the browser first.".into(),
- );
+ return Err(crate::backend_error("PROFILE_RUNNING").into());
}
let config = merge_submitted_wayfern_config(profile.wayfern_config.as_ref(), config);
+ let stored = profile.wayfern_config.as_ref();
+ let fingerprint_changed = config.fingerprint.is_some()
+ && config.fingerprint.as_ref() != stored.and_then(|c| c.fingerprint.as_ref());
+ let overrides_changed = config.identity_overrides.is_some()
+ && config.identity_overrides.as_ref() != stored.and_then(|c| c.identity_overrides.as_ref());
+ if (fingerprint_changed || overrides_changed)
+ && !CLOUD_AUTH.can_use_cross_os_fingerprints().await
+ {
+ return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into());
+ }
+ if !CLOUD_AUTH
+ .is_fingerprint_os_allowed(config.os.as_deref())
+ .await
+ {
+ return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into());
+ }
+
// Update the Wayfern configuration
+ if profile
+ .wayfern_config
+ .as_ref()
+ .map(serde_json::to_value)
+ .transpose()?
+ != Some(serde_json::to_value(&config)?)
+ {
+ profile.updated_at = Some(crate::proxy_manager::now_secs());
+ }
profile.wayfern_config = Some(config);
// Save the updated profile
self
.save_profile(&profile)
.map_err(|e| -> Box {
- format!("Failed to save profile: {e}").into()
+ crate::backend_error_with_detail("INTERNAL_ERROR", e).into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -2554,26 +2594,11 @@ pub async fn update_wayfern_config(
profile_id: String,
config: WayfernConfig,
) -> Result<(), String> {
- if (config.fingerprint.is_some() || config.identity_overrides.is_some())
- && !crate::cloud_auth::CLOUD_AUTH
- .can_use_cross_os_fingerprints()
- .await
- {
- return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
- }
-
- if !crate::cloud_auth::CLOUD_AUTH
- .is_fingerprint_os_allowed(config.os.as_deref())
- .await
- {
- return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
- }
-
let profile_manager = ProfileManager::instance();
profile_manager
.update_wayfern_config(app_handle, &profile_id, config)
.await
- .map_err(|e| format!("Failed to update Wayfern config: {e}"))
+ .map_err(|e| crate::wrap_backend_error(e, "Failed to update Wayfern config"))
}
#[tauri::command]
diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs
index 61e64be1..99695470 100644
--- a/src-tauri/src/profile/portable.rs
+++ b/src-tauri/src/profile/portable.rs
@@ -523,7 +523,10 @@ pub async fn import_profile_archive(
.map_err(|e| import_err(format!("its profile is malformed: {e}")))?;
drop(archive);
- let profile = imported_profile(&stored, &unique_imported_name(wanted, &taken))?;
+ let mut profile = imported_profile(&stored, &unique_imported_name(wanted, &taken))?;
+ if let Some(config) = profile.wayfern_config.as_mut() {
+ config.sanitize_launch_args().await;
+ }
let data_dir = manager
.get_profiles_dir()
.join(profile.id.to_string())
diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs
index 53d9fd42..c7aa1c81 100644
--- a/src-tauri/src/profile_importer.rs
+++ b/src-tauri/src/profile_importer.rs
@@ -922,6 +922,7 @@ impl ProfileImporter {
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
+ config.sanitize_launch_args().await;
// A caller-supplied device is a set of explicit field choices, not a
let supplied_device = if crate::wayfern_manager::supports_wayfern_152(&version) {
diff --git a/src-tauri/src/recipes.rs b/src-tauri/src/recipes.rs
new file mode 100644
index 00000000..a1e10376
--- /dev/null
+++ b/src-tauri/src/recipes.rs
@@ -0,0 +1,312 @@
+//! Client for the cloud recipe routes. Storage, validation rules and replay
+//! live in the cloud; this side only checks the shape before sending.
+
+use crate::cloud_errors::{self, BackendFailure, FailureCodes};
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+use std::sync::OnceLock;
+use std::time::Duration;
+
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+
+const RECIPE_CODES: FailureCodes = FailureCodes {
+ bad_request: "AGENT_RECIPE_INVALID",
+ forbidden: "RECIPES_NOT_ENTITLED",
+ not_found: "AGENT_RECIPE_NOT_FOUND",
+ conflict: "AGENT_RECIPE_INVALID",
+};
+
+pub type RecipeStep = serde_json::Value;
+
+const RECIPE_STEP_TYPES: [&str; 9] = [
+ "navigate",
+ "click",
+ "type",
+ "waitFor",
+ "extract",
+ "pressKey",
+ "scroll",
+ "screenshot",
+ "sleep",
+];
+
+const RECIPE_TARGETED_TYPES: [&str; 4] = ["click", "type", "waitFor", "extract"];
+
+const MAX_RECIPE_STEPS: usize = 200;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AgentRecipe {
+ pub id: String,
+ #[serde(default)]
+ pub name: String,
+ #[serde(default)]
+ pub steps: Vec,
+ #[serde(default)]
+ pub created_at: Option,
+ #[serde(default)]
+ pub updated_at: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum RecipeList {
+ Enveloped { recipes: Vec },
+ Bare(Vec),
+}
+
+impl RecipeList {
+ fn into_vec(self) -> Vec {
+ match self {
+ Self::Enveloped { recipes } => recipes,
+ Self::Bare(recipes) => recipes,
+ }
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RecipeDeleted {
+ #[serde(default)]
+ deleted: Option,
+}
+
+fn base() -> String {
+ format!("{}/api/agent/recipes", crate::cloud_auth::CLOUD_API_URL)
+}
+
+pub async fn list_recipes() -> Result, String> {
+ let list: RecipeList = request(reqwest::Method::GET, base(), None)
+ .await
+ .map_err(|e| failure("list", e))?;
+ Ok(list.into_vec())
+}
+
+pub async fn create_recipe(name: &str, steps: &[RecipeStep]) -> Result {
+ let (name, steps) = validate_recipe(name, steps)?;
+ request(
+ reqwest::Method::POST,
+ base(),
+ Some(serde_json::json!({ "name": name, "steps": steps })),
+ )
+ .await
+ .map_err(|e| failure("create", e))
+}
+
+pub async fn update_recipe(
+ id: &str,
+ name: &str,
+ steps: &[RecipeStep],
+) -> Result {
+ let (name, steps) = validate_recipe(name, steps)?;
+ request(
+ reqwest::Method::PATCH,
+ format!("{}/{}", base(), urlencoding::encode(id)),
+ Some(serde_json::json!({ "name": name, "steps": steps })),
+ )
+ .await
+ .map_err(|e| failure("update", e))
+}
+
+pub async fn delete_recipe(id: &str) -> Result {
+ let outcome: Option = request(
+ reqwest::Method::DELETE,
+ format!("{}/{}", base(), urlencoding::encode(id)),
+ None,
+ )
+ .await
+ .map_err(|e| failure("delete", e))?;
+ Ok(recipe_deleted(outcome))
+}
+
+// A 204 with no body is a successful delete.
+fn recipe_deleted(outcome: Option) -> bool {
+ outcome.and_then(|reply| reply.deleted).unwrap_or(true)
+}
+
+fn validate_recipe(name: &str, steps: &[RecipeStep]) -> Result<(String, Vec), String> {
+ let name = name.trim().to_string();
+ if name.is_empty() {
+ return Err(crate::backend_error("NAME_CANNOT_BE_EMPTY"));
+ }
+ if steps.is_empty() || steps.len() > MAX_RECIPE_STEPS {
+ return Err(crate::backend_error("AGENT_RECIPE_INVALID"));
+ }
+ for step in steps {
+ let Some(object) = step.as_object() else {
+ return Err(crate::backend_error("AGENT_RECIPE_INVALID"));
+ };
+ let Some(kind) = object.get("type").and_then(serde_json::Value::as_str) else {
+ return Err(crate::backend_error("AGENT_RECIPE_INVALID"));
+ };
+ if !RECIPE_STEP_TYPES.contains(&kind) {
+ return Err(crate::backend_error("AGENT_RECIPE_INVALID"));
+ }
+ if RECIPE_TARGETED_TYPES.contains(&kind) {
+ let has_selector = object
+ .get("selector")
+ .and_then(serde_json::Value::as_str)
+ .is_some_and(|selector| !selector.trim().is_empty());
+ let has_locator = object
+ .get("locator")
+ .is_some_and(|locator| locator.as_object().is_some_and(|fields| !fields.is_empty()));
+ if has_selector == has_locator {
+ return Err(crate::backend_error("AGENT_RECIPE_INVALID"));
+ }
+ }
+ }
+ Ok((name, steps.to_vec()))
+}
+
+fn failure(action: &str, err: BackendFailure) -> String {
+ log::warn!("Recipe {action} failed: {} (HTTP {})", err.code, err.status);
+ err.to_error_json()
+}
+
+fn http() -> &'static reqwest::Client {
+ static CLIENT: OnceLock = OnceLock::new();
+ CLIENT.get_or_init(|| {
+ reqwest::Client::builder()
+ .timeout(REQUEST_TIMEOUT)
+ .connect_timeout(CONNECT_TIMEOUT)
+ .build()
+ .unwrap_or_else(|_| reqwest::Client::new())
+ })
+}
+
+async fn request(
+ method: reqwest::Method,
+ url: String,
+ body: Option,
+) -> Result {
+ crate::cloud_auth::CLOUD_AUTH
+ .api_call_with_retry(|token| {
+ let method = method.clone();
+ let url = url.clone();
+ let body = body.clone();
+ async move {
+ let mut builder = http().request(method, &url).bearer_auth(token);
+ if let Some(payload) = body {
+ builder = builder.json(&payload);
+ }
+ let response = builder
+ .send()
+ .await
+ .map_err(|e| format!("reach backend: {e}"))?;
+ let status = response.status().as_u16();
+ if !(200..300).contains(&status) {
+ let text = response.text().await.unwrap_or_default();
+ // api_call_with_retry and classify_message read the status from this prefix.
+ return Err(format!("({status}) {text}"));
+ }
+ let bytes = response
+ .bytes()
+ .await
+ .map_err(|e| format!("decode response: {e}"))?;
+ decode_body::(&bytes).map_err(|e| format!("decode response: {e}"))
+ }
+ })
+ .await
+ .map_err(|e| cloud_errors::classify_message(&e, RECIPE_CODES))
+}
+
+fn decode_body(bytes: &[u8]) -> Result {
+ if bytes.iter().all(u8::is_ascii_whitespace) {
+ serde_json::from_slice(b"null")
+ } else {
+ serde_json::from_slice(bytes)
+ }
+}
+
+#[tauri::command]
+pub async fn get_agent_recipes() -> Result, String> {
+ list_recipes().await
+}
+
+#[tauri::command]
+pub async fn create_agent_recipe(
+ name: String,
+ steps: Vec,
+) -> Result {
+ create_recipe(&name, &steps).await
+}
+
+#[tauri::command]
+pub async fn update_agent_recipe(
+ id: String,
+ name: String,
+ steps: Vec,
+) -> Result {
+ update_recipe(&id, &name, &steps).await
+}
+
+#[tauri::command]
+pub async fn delete_agent_recipe(id: String) -> Result {
+ delete_recipe(&id).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_recipe_delete_with_no_body_is_a_success() {
+ let empty: Option = decode_body(b"").expect("a 204 has no body");
+ assert!(recipe_deleted(empty));
+ let blank: Option = decode_body(b" \n").expect("whitespace is no body");
+ assert!(recipe_deleted(blank));
+ let refused: Option =
+ decode_body(br#"{"deleted":false}"#).expect("an explicit answer");
+ assert!(!recipe_deleted(refused));
+ let bare: Option = decode_body(b"{}").expect("an empty object");
+ assert!(recipe_deleted(bare));
+ }
+
+ #[test]
+ fn an_empty_body_is_still_a_failure_where_a_value_is_required() {
+ assert!(decode_body::(b"").is_err());
+ assert!(decode_body::(b"").is_err());
+ assert!(decode_body::>(b"").is_err());
+ }
+
+ #[test]
+ fn a_recipe_needs_a_name_and_steps_the_api_will_accept() {
+ let navigate = serde_json::json!({"type": "navigate", "url": "https://example.com"});
+ assert!(validate_recipe(" ", std::slice::from_ref(&navigate))
+ .unwrap_err()
+ .contains("NAME_CANNOT_BE_EMPTY"));
+ assert!(validate_recipe("nightly", &[])
+ .unwrap_err()
+ .contains("AGENT_RECIPE_INVALID"));
+
+ for rejected in [
+ serde_json::json!("open the shop"),
+ serde_json::json!({"url": "https://example.com"}),
+ serde_json::json!({"type": "teleport", "url": "https://example.com"}),
+ serde_json::json!({"type": "click"}),
+ serde_json::json!({"type": "click", "selector": "#buy", "locator": {"role": "button"}}),
+ ] {
+ assert!(
+ validate_recipe("nightly", std::slice::from_ref(&rejected))
+ .unwrap_err()
+ .contains("AGENT_RECIPE_INVALID"),
+ "{rejected} must be refused"
+ );
+ }
+
+ let click = serde_json::json!({"type": "click", "locator": {"role": "button", "name": "Buy"}});
+ let (name, steps) = validate_recipe(" nightly ", &[navigate.clone(), click.clone()]).unwrap();
+ assert_eq!(name, "nightly");
+ assert_eq!(steps, vec![navigate, click]);
+ }
+
+ #[test]
+ fn the_recipe_list_decodes_bare_or_enveloped() {
+ let bare: RecipeList =
+ serde_json::from_str(r#"[{"id":"a","name":"A","steps":["x"]}]"#).unwrap();
+ assert_eq!(bare.into_vec().len(), 1);
+ let enveloped: RecipeList =
+ serde_json::from_str(r#"{"recipes":[{"id":"a","name":"A","steps":["x"]}]}"#).unwrap();
+ assert_eq!(enveloped.into_vec()[0].id, "a");
+ }
+}
diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs
index 184c1e63..ce7d09bb 100644
--- a/src-tauri/src/wayfern_manager.rs
+++ b/src-tauri/src/wayfern_manager.rs
@@ -65,6 +65,8 @@ pub struct WayfernConfig {
/// carry the host device for its whole lifetime.
#[serde(default)]
pub restore_session: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub launch_args: Vec,
#[serde(default)]
pub block_webgl: Option,
#[serde(default, skip_serializing)]
@@ -92,6 +94,281 @@ pub struct WayfernConfig {
pub location: Option,
}
+impl WayfernConfig {
+ pub async fn checked_launch_args(&self) -> Result, String> {
+ let has_args = self.launch_args.iter().any(|arg| !arg.trim().is_empty());
+ let allowed = !has_args
+ || crate::cloud_auth::CLOUD_AUTH
+ .can_use_browser_automation()
+ .await;
+ validate_launch_args(&self.launch_args, allowed)
+ }
+
+ /// For imports: keep only arguments this account may store, or none.
+ pub async fn sanitize_launch_args(&mut self) {
+ match self.checked_launch_args().await {
+ Ok(args) => self.launch_args = args,
+ Err(error) => {
+ log::warn!("Dropped imported launch arguments: {error}");
+ self.launch_args.clear();
+ }
+ }
+ }
+}
+
+pub const MAX_LAUNCH_ARGS: usize = 64;
+pub const MAX_LAUNCH_ARG_CHARS: usize = 1024;
+
+#[derive(Clone, Copy)]
+enum SwitchValue {
+ Flag,
+ OneOf(&'static [&'static str]),
+ Count(u32, u32),
+ Features,
+}
+
+// Arguments reach a profile from team members, sync storage and archives, so
+// only switches that cannot run programs, weaken the sandbox or change what
+// Donut controls are accepted.
+const ALLOWED_LAUNCH_SWITCHES: &[(&str, SwitchValue)] = &[
+ (
+ "--autoplay-policy",
+ SwitchValue::OneOf(&[
+ "no-user-gesture-required",
+ "user-gesture-required",
+ "document-user-activation-required",
+ ]),
+ ),
+ ("--disable-background-networking", SwitchValue::Flag),
+ (
+ "--disable-backgrounding-occluded-windows",
+ SwitchValue::Flag,
+ ),
+ ("--disable-default-apps", SwitchValue::Flag),
+ ("--disable-features", SwitchValue::Features),
+ ("--disable-gpu", SwitchValue::Flag),
+ ("--disable-gpu-compositing", SwitchValue::Flag),
+ ("--disable-hang-monitor", SwitchValue::Flag),
+ ("--disable-ipc-flooding-protection", SwitchValue::Flag),
+ ("--disable-notifications", SwitchValue::Flag),
+ ("--disable-pinch", SwitchValue::Flag),
+ ("--disable-popup-blocking", SwitchValue::Flag),
+ ("--disable-print-preview", SwitchValue::Flag),
+ ("--disable-renderer-backgrounding", SwitchValue::Flag),
+ ("--disable-session-crashed-bubble", SwitchValue::Flag),
+ ("--disable-smooth-scrolling", SwitchValue::Flag),
+ ("--disable-software-rasterizer", SwitchValue::Flag),
+ ("--enable-features", SwitchValue::Features),
+ ("--enable-gpu-rasterization", SwitchValue::Flag),
+ ("--enable-smooth-scrolling", SwitchValue::Flag),
+ ("--enable-zero-copy", SwitchValue::Flag),
+ ("--force-dark-mode", SwitchValue::Flag),
+ ("--hide-scrollbars", SwitchValue::Flag),
+ ("--ignore-gpu-blocklist", SwitchValue::Flag),
+ ("--mute-audio", SwitchValue::Flag),
+ ("--num-raster-threads", SwitchValue::Count(1, 16)),
+ ("--process-per-site", SwitchValue::Flag),
+ ("--renderer-process-limit", SwitchValue::Count(1, 64)),
+];
+
+// Switches Donut sets or depends on; named in the refusal so the user knows why.
+const DONUT_MANAGED_SWITCHES: &[&str] = &[
+ "--accept-lang",
+ "--crash-server-url",
+ "--disable-extensions-except",
+ "--disk-cache-dir",
+ "--disk-cache-size",
+ "--enable-logging",
+ "--headless",
+ "--host-resolver-rules",
+ "--host-rules",
+ "--kiosk",
+ "--lang",
+ "--load-extension",
+ "--log-file",
+ "--log-level",
+ "--no-proxy-server",
+ "--no-sandbox",
+ "--password-store",
+ "--profile-directory",
+ "--restore-last-session",
+ "--start-fullscreen",
+ "--start-maximized",
+ "--type",
+ "--use-mock-keychain",
+ "--user-agent",
+ "--user-data-dir",
+ "--window-position",
+ "--window-size",
+];
+
+// Lowercase fragments of feature names that touch privacy, the network path,
+// the sandbox or fingerprint surfaces.
+const BLOCKED_FEATURE_FRAGMENTS: &[&str] = &[
+ "automation",
+ "battery",
+ "bluetooth",
+ "canvas",
+ "certificate",
+ "clienthint",
+ "cookie",
+ "devtools",
+ "dns",
+ "extension",
+ "fingerprint",
+ "font",
+ "gamepad",
+ "geolocation",
+ "headless",
+ "hid",
+ "incognito",
+ "insecure",
+ "isolation",
+ "language",
+ "locale",
+ "partition",
+ "prefetch",
+ "privacy",
+ "privatenetwork",
+ "proxy",
+ "remote",
+ "sandbox",
+ "sensor",
+ "serial",
+ "storage",
+ "sync",
+ "thirdparty",
+ "timezone",
+ "tracking",
+ "unsafe",
+ "update",
+ "usb",
+ "useragent",
+ "wayfern",
+ "webgl",
+ "webgpu",
+ "webrtc",
+];
+
+fn feature_list_allowed(value: &str) -> bool {
+ !value.is_empty()
+ && value.split(',').all(|feature| {
+ let lower = feature.to_ascii_lowercase();
+ feature.starts_with(|c: char| c.is_ascii_alphabetic())
+ && feature.chars().all(|c| c.is_ascii_alphanumeric())
+ && !BLOCKED_FEATURE_FRAGMENTS
+ .iter()
+ .any(|fragment| lower.contains(fragment))
+ })
+}
+
+fn launch_arg_error(code: &str, name: &str) -> String {
+ serde_json::json!({ "code": code, "params": { "argument": name } }).to_string()
+}
+
+fn validate_launch_args(args: &[String], allowed: bool) -> Result, String> {
+ let args: Vec<&str> = args
+ .iter()
+ .map(|arg| arg.trim())
+ .filter(|arg| !arg.is_empty())
+ .collect();
+ if args.is_empty() {
+ return Ok(Vec::new());
+ }
+ if !allowed {
+ return Err(crate::backend_error("WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"));
+ }
+ if args.len() > MAX_LAUNCH_ARGS
+ || args
+ .iter()
+ .any(|arg| arg.chars().count() > MAX_LAUNCH_ARG_CHARS)
+ {
+ return Err(
+ serde_json::json!({
+ "code": "WAYFERN_LAUNCH_ARGS_LIMIT",
+ "params": {
+ "count": MAX_LAUNCH_ARGS.to_string(),
+ "length": MAX_LAUNCH_ARG_CHARS.to_string(),
+ }
+ })
+ .to_string(),
+ );
+ }
+ let mut result = Vec::new();
+ for arg in args {
+ let (name, value) = match arg.split_once('=') {
+ Some((name, value)) => (name, Some(value)),
+ None => (arg, None),
+ };
+ let valid_name = name.strip_prefix("--").is_some_and(|name| {
+ name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
+ && name
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
+ });
+ if !valid_name || arg.chars().any(char::is_control) {
+ return Err(crate::backend_error("WAYFERN_LAUNCH_ARG_INVALID"));
+ }
+ let Some((_, kind)) = ALLOWED_LAUNCH_SWITCHES
+ .iter()
+ .find(|(allowed, _)| *allowed == name)
+ else {
+ let managed = DONUT_MANAGED_SWITCHES.contains(&name)
+ || name.starts_with("--remote-debugging-")
+ || name.starts_with("--proxy-")
+ || name.starts_with("--wayfern-");
+ return Err(launch_arg_error(
+ if managed {
+ "WAYFERN_LAUNCH_ARG_RESERVED"
+ } else {
+ "WAYFERN_LAUNCH_ARG_NOT_ALLOWED"
+ },
+ name,
+ ));
+ };
+ let value_ok = match (kind, value) {
+ (SwitchValue::Flag, None) => true,
+ (SwitchValue::OneOf(choices), Some(value)) => choices.contains(&value),
+ (SwitchValue::Count(min, max), Some(value)) => value
+ .parse::()
+ .is_ok_and(|count| (*min..=*max).contains(&count)),
+ (SwitchValue::Features, Some(value)) => feature_list_allowed(value),
+ _ => false,
+ };
+ if !value_ok {
+ return Err(launch_arg_error("WAYFERN_LAUNCH_ARG_VALUE_INVALID", name));
+ }
+ result.push(arg.to_string());
+ }
+ Ok(result)
+}
+
+fn apply_launch_args(args: &mut Vec, custom: &[String]) {
+ for arg in custom {
+ let name = arg.split('=').next().unwrap_or_default();
+ if name == "--disable-features" || name == "--enable-features" {
+ let mut features = Vec::new();
+ for value in args.iter().chain(std::iter::once(arg)) {
+ if let Some((key, value)) = value.split_once('=') {
+ if key == name {
+ for feature in value.split(',').filter(|feature| !feature.is_empty()) {
+ if !features.contains(&feature) {
+ features.push(feature);
+ }
+ }
+ }
+ }
+ }
+ let merged = format!("{name}={}", features.join(","));
+ args.retain(|existing| existing.split('=').next() != Some(name));
+ args.push(merged);
+ } else {
+ args.retain(|existing| existing.split('=').next() != Some(name));
+ args.push(arg.clone());
+ }
+ }
+}
+
/// First Wayfern version that ships `createIdentity`/`setIdentity`/
/// `getIdentity`. Those commands are ADDITIVE: `setFingerprint`,
/// `getFingerprint` and `refreshFingerprint` are all still declared in the 151
@@ -2273,6 +2550,20 @@ impl WayfernManager {
headless: bool,
kind: LaunchKind,
) -> Result> {
+ let custom_launch_args = match config.checked_launch_args().await {
+ Ok(args) => args,
+ Err(error) => {
+ log::warn!(
+ "Launching profile {} without its custom launch arguments: {error}",
+ profile.name
+ );
+ let _ = crate::events::emit(
+ "profile-launch-args-skipped",
+ serde_json::json!({ "profile_name": profile.name, "error": error }),
+ );
+ Vec::new()
+ }
+ };
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
@@ -2676,6 +2967,7 @@ impl WayfernManager {
);
}
+ apply_launch_args(&mut args, &custom_launch_args);
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
@@ -3514,6 +3806,198 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
mod tests {
use super::*;
+ #[test]
+ fn launch_args_default_and_round_trip() {
+ let legacy: WayfernConfig = serde_json::from_str("{}").unwrap();
+ assert!(legacy.launch_args.is_empty());
+ let config: WayfernConfig = serde_json::from_str(
+ r#"{"launch_args":["--disable-gpu","--disk-cache-dir=/tmp/cache with spaces"]}"#,
+ )
+ .unwrap();
+ let saved = serde_json::to_string(&config).unwrap();
+ let loaded: WayfernConfig = serde_json::from_str(&saved).unwrap();
+ assert_eq!(loaded.launch_args, config.launch_args);
+ }
+
+ fn code_and_argument(error: &str) -> (String, String) {
+ let value: serde_json::Value = serde_json::from_str(error).unwrap();
+ (
+ value["code"].as_str().unwrap_or_default().to_string(),
+ value["params"]["argument"]
+ .as_str()
+ .unwrap_or_default()
+ .to_string(),
+ )
+ }
+
+ #[test]
+ fn launch_args_accept_supported_switches_as_single_arguments() {
+ let args = vec![
+ " --disable-gpu ".to_string(),
+ "".to_string(),
+ "--autoplay-policy=no-user-gesture-required".to_string(),
+ "--renderer-process-limit=8".to_string(),
+ "--disable-features=OverlayScrollbar,TranslateUI".to_string(),
+ ];
+ assert_eq!(
+ validate_launch_args(&args, true).unwrap(),
+ vec![
+ "--disable-gpu",
+ "--autoplay-policy=no-user-gesture-required",
+ "--renderer-process-limit=8",
+ "--disable-features=OverlayScrollbar,TranslateUI",
+ ]
+ );
+ }
+
+ #[test]
+ fn launch_args_need_automation_but_clearing_them_does_not() {
+ assert!(validate_launch_args(&[], false).unwrap().is_empty());
+ assert!(validate_launch_args(&[" ".to_string()], false)
+ .unwrap()
+ .is_empty());
+ let error = validate_launch_args(&["--disable-gpu".to_string()], false).unwrap_err();
+ assert_eq!(
+ code_and_argument(&error).0,
+ "WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"
+ );
+ }
+
+ #[test]
+ fn launch_args_refuse_switches_that_run_programs_or_weaken_the_browser() {
+ for arg in [
+ "--renderer-cmd-prefix=/tmp/x",
+ "--gpu-launcher=/tmp/x",
+ "--utility-cmd-prefix=/tmp/x",
+ "--browser-subprocess-path=/tmp/x",
+ "--zygote-cmd-prefix=/tmp/x",
+ "--disable-web-security",
+ "--ignore-certificate-errors",
+ "--allow-file-access-from-files",
+ "--remote-allow-origins=*",
+ "--custom-devtools-frontend=http://x",
+ "--js-flags=--expose-gc",
+ "--single-process",
+ "--log-net-log=/tmp/net.json",
+ "--force-webrtc-ip-handling-policy=default",
+ "--disable-blink-features=AutomationControlled",
+ "--enable-automation",
+ ] {
+ let error = validate_launch_args(&[arg.to_string()], true).unwrap_err();
+ let (code, argument) = code_and_argument(&error);
+ assert_eq!(code, "WAYFERN_LAUNCH_ARG_NOT_ALLOWED", "{arg}");
+ assert_eq!(argument, arg.split('=').next().unwrap());
+ }
+ }
+
+ #[test]
+ fn launch_args_name_the_switches_donut_controls() {
+ for arg in [
+ "--user-data-dir=/tmp/other",
+ "--profile-directory=Other",
+ "--remote-debugging-port=9222",
+ "--remote-debugging-pipe",
+ "--proxy-server=http://other:8080",
+ "--no-proxy-server",
+ "--host-resolver-rules=MAP * other",
+ "--host-rules=MAP * other",
+ "--headless=new",
+ "--wayfern-identity-file=/tmp/other",
+ "--window-size=800,600",
+ "--load-extension=/tmp/ext",
+ "--enable-logging=stderr",
+ "--log-level=3",
+ "--password-store=gnome",
+ "--no-sandbox",
+ "--user-agent=Other",
+ "--lang=de",
+ "--disk-cache-dir=/tmp/cache",
+ ] {
+ let error = validate_launch_args(&[arg.to_string()], true).unwrap_err();
+ let (code, argument) = code_and_argument(&error);
+ assert_eq!(code, "WAYFERN_LAUNCH_ARG_RESERVED", "{arg}");
+ assert_eq!(argument, arg.split('=').next().unwrap());
+ }
+ }
+
+ #[test]
+ fn launch_args_reject_malformed_input_and_bad_values() {
+ for arg in [
+ "--",
+ "https://example.com",
+ "--window-size 800,600",
+ "--USER-DATA-DIR=/tmp/other",
+ "-disable-gpu",
+ "/disable-gpu",
+ "--disable\u{200b}-gpu",
+ "--value=one\ntwo",
+ "--value=one\0two",
+ ] {
+ let error = validate_launch_args(&[arg.to_string()], true).unwrap_err();
+ assert!(error.contains("WAYFERN_LAUNCH_ARG_INVALID"), "{arg:?}");
+ }
+ for arg in [
+ "--disable-gpu=1",
+ "--autoplay-policy=always",
+ "--renderer-process-limit=0",
+ "--renderer-process-limit=1000",
+ "--num-raster-threads=two",
+ "--disable-features=",
+ "--disable-features=WebRtcHideLocalIpsWithMdns",
+ "--enable-features=WayfernSomething",
+ "--enable-features=Good,AsyncDns",
+ "--enable-features=Name("overview");
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
- const [agentInitialTab, setAgentInitialTab] = useState("run");
+ const [agentInitialTab, setAgentInitialTab] =
+ useState("conversation");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
// A settings section to land on, set by a tip's action for one opening.
@@ -655,6 +657,15 @@ export default function Home() {
}
}, []);
+ const openAgentConversation = useCallback(() => {
+ setAgentInitialTab("conversation");
+ handleRailNavigate("agent");
+ }, [handleRailNavigate]);
+ const agentConsole = useAgentConsole({
+ onOpenPage: openAgentConversation,
+ pageVisible: currentPage === "agent",
+ });
+
const runTipAction = useCallback(
(action: TipAction) => {
closeTips();
@@ -737,13 +748,14 @@ export default function Home() {
break;
}
case "goAgent": {
- // Mod+J: navigate first time; flip run↔history while already there,
- // matching how Mod+B flips the Cookie Bot tabs.
+ // Mod+J: navigate first time; flip conversation↔activity while
+ // already there, matching how Mod+B flips the Cookie Bot tabs.
if (currentPage === "agent") {
- setAgentInitialTab((cur) => (cur === "run" ? "history" : "run"));
+ setAgentInitialTab((cur) =>
+ cur === "conversation" ? "activity" : "conversation",
+ );
} else {
- setAgentInitialTab("run");
- handleRailNavigate("agent");
+ openAgentConversation();
}
break;
}
@@ -781,6 +793,7 @@ export default function Home() {
proxyManagementInitialTab,
cloudUser,
openTips,
+ openAgentConversation,
],
);
@@ -1130,13 +1143,10 @@ export default function Home() {
setWayfernConfigDialogOpen(false);
} catch (err: unknown) {
console.error("Failed to update wayfern config:", err);
- showErrorToast(
- t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
- );
throw err;
}
},
- [t],
+ [],
);
const handleCreateProfile = useCallback(
@@ -1245,6 +1255,26 @@ export default function Home() {
};
}, [t]);
+ useEffect(() => {
+ const unlisten = listen<{ profile_name: string; error: string }>(
+ "profile-launch-args-skipped",
+ (event) => {
+ showErrorToast(
+ t("fingerprint.launchArgsSkipped", {
+ profile: event.payload.profile_name,
+ reason: translateBackendError(t, event.payload.error),
+ }),
+ { id: `launch-args-skipped-${event.payload.profile_name}` },
+ );
+ },
+ );
+ return () => {
+ void unlisten.then((fn) => {
+ fn();
+ });
+ };
+ }, [t]);
+
// The third state. The gate reached the exit but the fingerprint declares no
// value to compare it against, so nothing was verified, which is not a
// mismatch and never blocks, but is not the clean bill of health that
@@ -2320,6 +2350,7 @@ export default function Home() {
}}
onOpenTips={() => openTips()}
cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0}
+ agentRequests={agentConsole.openRequests.length}
/>
{currentPage === "profiles" && (
@@ -2496,8 +2527,17 @@ export default function Home() {
}}
subPage={currentPage === "agent"}
initialTab={agentInitialTab}
+ onTabChange={setAgentInitialTab}
profiles={profiles}
cloudUser={cloudUser}
+ agentConsole={agentConsole}
+ onConnectAgent={() => {
+ setIntegrationsInitialTab("remote");
+ handleRailNavigate("integrations");
+ }}
+ onOpenAccount={() => {
+ handleRailNavigate("account");
+ }}
/>
)}
diff --git a/src/components/agent-activity.tsx b/src/components/agent-activity.tsx
new file mode 100644
index 00000000..b870eb8e
--- /dev/null
+++ b/src/components/agent-activity.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useId, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { LuTrash2 } from "react-icons/lu";
+import {
+ activityDetailText,
+ useTimeFormat,
+} from "@/components/agent-console-parts";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { AgentConsole } from "@/hooks/use-agent-console";
+import {
+ type AgentActivity,
+ activityStats,
+ sessionLabel,
+} from "@/lib/agent-console";
+import { cn } from "@/lib/utils";
+
+const ALL = "__all__";
+const ROW_HEIGHT = 36;
+
+export function AgentActivityFeed({
+ agentConsole,
+ profileNames,
+ now,
+}: {
+ agentConsole: AgentConsole;
+ profileNames: Map;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const { clock, duration } = useTimeFormat();
+ const [agent, setAgent] = useState(ALL);
+ const [errorsOnly, setErrorsOnly] = useState(false);
+ const [query, setQuery] = useState("");
+ const [clearing, setClearing] = useState(false);
+ const scrollRef = useRef(null);
+ const agentId = useId();
+ const errorsId = useId();
+ const searchId = useId();
+
+ const fallback = t("agent.sessions.fallback");
+ const website = t("agent.sessions.website");
+ const sessionsById = useMemo(
+ () =>
+ new Map(
+ agentConsole.sessions.map((session) => [session.session_id, session]),
+ ),
+ [agentConsole.sessions],
+ );
+
+ const entries = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ const out: AgentActivity[] = [];
+ for (let index = agentConsole.activity.length - 1; index >= 0; index -= 1) {
+ const entry = agentConsole.activity[index];
+ if (agent !== ALL && entry.session_id !== agent) continue;
+ if (errorsOnly && entry.ok) continue;
+ if (needle) {
+ const name = entry.profile_id
+ ? (profileNames.get(entry.profile_id) ?? "")
+ : "";
+ if (!name.toLowerCase().includes(needle)) continue;
+ }
+ out.push(entry);
+ }
+ return out;
+ }, [agentConsole.activity, agent, errorsOnly, query, profileNames]);
+
+ const stats = useMemo(
+ () => activityStats(agentConsole.activity, now),
+ [agentConsole.activity, now],
+ );
+
+ const virtualizer = useVirtualizer({
+ count: entries.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: 12,
+ });
+
+ return (
+
+
+
+ {t("agent.activity.agentFilter")}
+
+
+
+
+
+
+ {t("agent.sessions.all")}
+ {agentConsole.sessions.map((session) => (
+
+ {sessionLabel(session, fallback, website)}
+
+ ))}
+
+
+
+ {t("agent.activity.profileSearch")}
+
+
{
+ setQuery(event.target.value);
+ }}
+ />
+
+ {
+ setErrorsOnly(checked === true);
+ }}
+ />
+
+ {t("agent.filters.errorsOnly")}
+
+
+
{
+ setClearing(true);
+ await agentConsole.clearActivity();
+ setClearing(false);
+ }}
+ >
+
+ {t("common.buttons.clear")}
+
+
+
+
+ {stats.calls === 0
+ ? t("agent.activity.statsNone")
+ : t("agent.activity.stats", {
+ calls: stats.calls,
+ rate: Math.round(stats.errorRate * 100),
+ average: duration(stats.averageMs ?? 0),
+ })}
+
+
+
+ {entries.length === 0 ? (
+
+ {agentConsole.activity.length === 0
+ ? t("agent.activity.empty")
+ : t("agent.activity.noMatches")}
+
+ ) : (
+
+ {virtualizer.getVirtualItems().map((row) => {
+ const entry = entries[row.index];
+ const detail = activityDetailText(t, entry);
+ return (
+
+
+ {clock(entry.at)}
+
+
+ {entry.session_id
+ ? sessionLabel(
+ sessionsById.get(entry.session_id),
+ fallback,
+ website,
+ )
+ : fallback}
+
+
+ {entry.tool}
+
+
+ {entry.profile_id
+ ? (profileNames.get(entry.profile_id) ??
+ t("common.labels.unknownProfile"))
+ : ""}
+
+
+ {detail ?? ""}
+
+
+ {duration(entry.duration_ms)}
+
+
+ {entry.ok
+ ? t("agent.result.ok")
+ : (entry.error_code ?? t("agent.result.error"))}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/agent-console-parts.tsx b/src/components/agent-console-parts.tsx
new file mode 100644
index 00000000..adca3ea7
--- /dev/null
+++ b/src/components/agent-console-parts.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import type { TFunction } from "i18next";
+import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ type AgentActivity,
+ activityDetail,
+ MAX_TEXT_CHARS,
+} from "@/lib/agent-console";
+import { cn } from "@/lib/utils";
+
+export function useNow(intervalMs = 15_000): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ setNow(Date.now());
+ }, intervalMs);
+ return () => {
+ window.clearInterval(id);
+ };
+ }, [intervalMs]);
+ return now;
+}
+
+const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ["day", 86_400],
+ ["hour", 3600],
+ ["minute", 60],
+];
+
+export function useTimeFormat() {
+ const { t, i18n } = useTranslation();
+ const language = i18n.language;
+
+ const relative = useCallback(
+ (at: number, now: number) => {
+ const formatter = new Intl.RelativeTimeFormat(language, {
+ numeric: "auto",
+ });
+ const seconds = Math.round((at - now) / 1000);
+ for (const [unit, size] of RELATIVE_UNITS) {
+ if (Math.abs(seconds) >= size) {
+ return formatter.format(Math.round(seconds / size), unit);
+ }
+ }
+ return formatter.format(0, "second");
+ },
+ [language],
+ );
+
+ const clock = useCallback(
+ (at: number) =>
+ new Date(at).toLocaleTimeString(language, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }),
+ [language],
+ );
+
+ const shortClock = useCallback(
+ (at: number) =>
+ new Date(at).toLocaleTimeString(language, {
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ [language],
+ );
+
+ const duration = useCallback(
+ (ms: number) =>
+ ms < 1000
+ ? t("agent.activity.durationMs", { ms })
+ : t("agent.activity.durationSeconds", {
+ seconds: (ms / 1000).toFixed(ms < 10_000 ? 1 : 0),
+ }),
+ [t],
+ );
+
+ return { relative, clock, shortClock, duration };
+}
+
+export function NoteAction({
+ label,
+ confirmLabel,
+ title,
+ placeholder,
+ icon,
+ onConfirm,
+ disabled,
+ variant = "outline",
+ size = "sm",
+ testId,
+ className,
+}: {
+ label: string;
+ confirmLabel?: string;
+ title?: string;
+ placeholder?: string;
+ icon?: ReactNode;
+ onConfirm: (note: string | null) => Promise;
+ disabled?: boolean;
+ variant?: "outline" | "default" | "ghost" | "secondary";
+ size?: "sm" | "default";
+ testId?: string;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [note, setNote] = useState("");
+ const [busy, setBusy] = useState(false);
+ const noteId = useId();
+
+ const submit = async () => {
+ setBusy(true);
+ const trimmed = note.trim();
+ const ok = await onConfirm(trimmed.length > 0 ? trimmed : null);
+ setBusy(false);
+ if (ok) {
+ setNote("");
+ setOpen(false);
+ }
+ };
+
+ return (
+
+
+
+ {icon}
+ {label}
+
+
+
+
+
+
+ );
+}
+
+/** What the page shows in place of a feature that cannot work yet. */
+export function SetupPanel({
+ icon,
+ title,
+ body,
+ testId,
+ children,
+ className,
+}: {
+ icon: ReactNode;
+ title: string;
+ body: string;
+ testId: string;
+ children?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {icon}
+ {title}
+ {body}
+ {children}
+
+ );
+}
+
+export function ProgressBar({
+ percent,
+ label,
+}: {
+ percent: number;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+export function activityDetailText(
+ t: TFunction,
+ entry: AgentActivity,
+): string | null {
+ const detail = activityDetail(entry);
+ if (!detail) return null;
+ switch (detail.kind) {
+ case "host":
+ return detail.host;
+ case "typed":
+ return t("agent.activity.typed", { count: detail.count });
+ case "profiles":
+ return t("agent.activity.profiles", { count: detail.count });
+ }
+}
diff --git a/src/components/agent-conversation.tsx b/src/components/agent-conversation.tsx
new file mode 100644
index 00000000..7a2b859c
--- /dev/null
+++ b/src/components/agent-conversation.tsx
@@ -0,0 +1,913 @@
+"use client";
+
+import { useEffect, useId, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ LuActivity,
+ LuAppWindow,
+ LuCheck,
+ LuChevronsUpDown,
+ LuHand,
+ LuMessageCircleQuestion,
+ LuSend,
+ LuUndo2,
+} from "react-icons/lu";
+import {
+ NoteAction,
+ ProgressBar,
+ useTimeFormat,
+} from "@/components/agent-console-parts";
+import { StatusDot } from "@/components/cookie-bot-shared";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Textarea } from "@/components/ui/textarea";
+import type { AgentConsole } from "@/hooks/use-agent-console";
+import {
+ type AgentSession,
+ type AgentThreadItem,
+ collapseProgressRuns,
+ isSessionActive,
+ isSessionEarlier,
+ MAX_TEXT_CHARS,
+ progressPercent,
+ sessionLabel,
+ threadForSession,
+} from "@/lib/agent-console";
+import { cn } from "@/lib/utils";
+import type { BrowserProfile } from "@/types";
+
+type SessionNamer = (sessionId: string | null) => string;
+
+function useSessionNamer(sessions: readonly AgentSession[]): SessionNamer {
+ const { t } = useTranslation();
+ return useMemo(() => {
+ const byId = new Map(
+ sessions.map((session) => [session.session_id, session]),
+ );
+ const fallback = t("agent.sessions.fallback");
+ const website = t("agent.sessions.website");
+ return (sessionId: string | null) =>
+ sessionLabel(sessionId ? byId.get(sessionId) : null, fallback, website);
+ }, [sessions, t]);
+}
+
+function ProfileChip({ name }: { name: string }) {
+ return (
+
+ {name}
+
+ );
+}
+
+function RequestCard({
+ item,
+ agentName,
+ profileName,
+ agentConsole,
+ now,
+}: {
+ item: AgentThreadItem;
+ agentName: string;
+ profileName: string | null;
+ agentConsole: AgentConsole;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const { relative } = useTimeFormat();
+ const [answer, setAnswer] = useState("");
+ const [busy, setBusy] = useState(false);
+ const baseId = useId();
+ const isHelp = item.kind === "help";
+
+ const send = async (text: string) => {
+ setBusy(true);
+ const ok = await agentConsole.answer(item.id, text);
+ setBusy(false);
+ if (ok) setAnswer("");
+ };
+
+ const dismissButton = (
+ {
+ void agentConsole.dismiss(item.id);
+ }}
+ >
+ {t("agent.actions.dismiss")}
+
+ );
+
+ return (
+
+
+ {isHelp ? (
+
+ ) : (
+
+ )}
+
+ {t(
+ isHelp ? "agent.request.helpTitle" : "agent.request.questionTitle",
+ { agent: agentName },
+ )}
+
+
+ {relative(item.at, now)}
+
+ {profileName && }
+
+
+ {item.text}
+
+ {isHelp ? (
+
+ {item.profile_id && (
+ {
+ if (item.profile_id)
+ void agentConsole.showWindow(item.profile_id);
+ }}
+ >
+
+ {t("agent.actions.showWindow")}
+
+ )}
+ }
+ placeholder={t("agent.actions.handBackPlaceholder")}
+ testId="agent-hand-back"
+ onConfirm={(note) => agentConsole.answer(item.id, note ?? "")}
+ />
+ {dismissButton}
+
+ ) : (
+
+ {item.choices.length > 0 && (
+
+ {item.choices.map((choice) => (
+ {
+ void send(choice);
+ }}
+ >
+ {choice}
+
+ ))}
+
+ )}
+
{
+ event.preventDefault();
+ if (answer.trim()) void send(answer.trim());
+ }}
+ >
+
+ {t("agent.request.answerLabel")}
+
+ {
+ setAnswer(event.target.value);
+ }}
+ />
+
+
+ {t("agent.request.send")}
+
+
+ {dismissButton}
+
+ )}
+
+ );
+}
+
+/** Open questions and help requests from every agent. */
+export function NeedsYouList({
+ agentConsole,
+ profileNames,
+ now,
+}: {
+ agentConsole: AgentConsole;
+ profileNames: Map;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const nameOf = useSessionNamer(agentConsole.sessions);
+ const requests = agentConsole.openRequests;
+
+ return (
+
+
+ {t("agent.request.needsYou")}
+ {requests.length}
+
+
+ {t("agent.request.waitingCount", { count: requests.length })}
+
+ {requests.map((item) => (
+
+ ))}
+
+ );
+}
+
+function SessionButton({
+ session,
+ selected,
+ onSelect,
+ now,
+}: {
+ session: AgentSession;
+ selected: boolean;
+ onSelect: () => void;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const active = isSessionActive(session, now);
+ const percent = session.status
+ ? progressPercent(session.status.done, session.status.total)
+ : null;
+ const name = sessionLabel(
+ session,
+ t("agent.sessions.fallback"),
+ t("agent.sessions.website"),
+ );
+ return (
+
+
+
+ {name}
+ {session.client_version && (
+
+ {session.client_version}
+
+ )}
+
+ {session.ended
+ ? t("agent.sessions.ended")
+ : active
+ ? t("agent.sessions.active")
+ : t("agent.sessions.idle")}
+
+
+ {session.status && (
+
+ {session.status.message}
+
+ )}
+ {session.status && percent !== null && (
+
+
+
+ {t("agent.sessions.progress", {
+ done: session.status.done ?? 0,
+ total: session.status.total ?? 0,
+ })}
+
+
+ )}
+ 0
+ ? "text-destructive-text"
+ : "text-muted-foreground",
+ )}
+ >
+ {t("agent.sessions.counts", {
+ calls: session.calls,
+ errors: session.errors,
+ })}
+
+
+ );
+}
+
+function SessionList({
+ sessions,
+ selectedSessionId,
+ onSelectSession,
+ now,
+}: {
+ sessions: AgentSession[];
+ selectedSessionId: string | null;
+ onSelectSession: (sessionId: string | null) => void;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const [showEarlier, setShowEarlier] = useState(false);
+ const earlier = sessions.filter(
+ (session) =>
+ isSessionEarlier(session, now) &&
+ session.session_id !== selectedSessionId,
+ );
+ const visible = showEarlier
+ ? sessions
+ : sessions.filter((session) => !earlier.includes(session));
+
+ return (
+
+ {
+ onSelectSession(null);
+ }}
+ className={cn(
+ "flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
+ selectedSessionId === null && "bg-accent text-accent-foreground",
+ )}
+ >
+ {t("agent.sessions.all")}
+
+ {sessions.filter((session) => isSessionActive(session, now)).length}
+
+
+ {visible.map((session) => (
+ {
+ onSelectSession(session.session_id);
+ }}
+ now={now}
+ />
+ ))}
+ {earlier.length > 0 && (
+ {
+ setShowEarlier((value) => !value);
+ }}
+ className="cursor-pointer rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
+ >
+ {showEarlier
+ ? t("agent.sessions.hideEarlier")
+ : t("agent.sessions.showEarlier", { count: earlier.length })}
+
+ )}
+
+ );
+}
+
+function ThreadEntry({
+ item,
+ nameOf,
+ profileName,
+ now,
+}: {
+ item: AgentThreadItem;
+ nameOf: SessionNamer;
+ profileName: string | null;
+ now: number;
+}) {
+ const { t } = useTranslation();
+ const { relative, shortClock } = useTimeFormat();
+ const time = (
+
+ {relative(item.at, now)}
+
+ );
+
+ if (item.kind === "joined" || item.kind === "left") {
+ return (
+
+ {t(
+ item.kind === "joined" ? "agent.thread.joined" : "agent.thread.left",
+ { agent: nameOf(item.session_id) },
+ )}
+ ·
+ {time}
+
+ );
+ }
+
+ if (item.kind === "progress") {
+ const hasTotal = item.done !== null && item.total !== null;
+ return (
+
+
+ {nameOf(item.session_id)}
+ {item.text}
+ {hasTotal && (
+
+ {t("agent.sessions.progress", {
+ done: item.done,
+ total: item.total,
+ })}
+
+ )}
+ {time}
+
+ );
+ }
+
+ if (item.kind === "note") {
+ const delivery =
+ item.state === "delivered"
+ ? item.answered_at
+ ? t("agent.thread.noteSeenAt", { time: shortClock(item.answered_at) })
+ : t("agent.thread.noteSeen")
+ : t("agent.thread.notePending");
+ return (
+
+
+ {item.text}
+
+
+
+ {item.session_id
+ ? t("agent.thread.noteTo", { agent: nameOf(item.session_id) })
+ : t("agent.thread.noteToAll")}
+
+ {profileName && }
+ ·
+
+ {delivery}
+
+ ·
+ {time}
+
+
+ );
+ }
+
+ const isHelp = item.kind === "help";
+ let outcome: string;
+ if (item.state === "open") outcome = t("agent.thread.waiting");
+ else if (item.state === "dismissed") outcome = t("agent.thread.dismissed");
+ else if (isHelp)
+ outcome = item.answer
+ ? t("agent.thread.handedBackNote", { note: item.answer })
+ : t("agent.thread.handedBack");
+ else outcome = t("agent.thread.answered", { answer: item.answer ?? "" });
+
+ return (
+
+
+
+ {nameOf(item.session_id)}
+
+ {profileName && }
+ ·
+ {time}
+
+
+ {isHelp ? (
+
+ ) : (
+
+ )}
+
+ {item.text}
+
+
+
+ {outcome}
+
+
+ );
+}
+
+function ProfilePicker({
+ profiles,
+ value,
+ onChange,
+ preferredIds,
+}: {
+ profiles: BrowserProfile[];
+ value: string | null;
+ onChange: (profileId: string | null) => void;
+ preferredIds: readonly string[];
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const listId = useId();
+
+ const options = useMemo(() => {
+ const preferred = new Map(preferredIds.map((id, index) => [id, index]));
+ const needle = query.trim().toLowerCase();
+ const matches = needle
+ ? profiles.filter((profile) =>
+ profile.name.toLowerCase().includes(needle),
+ )
+ : profiles;
+ return [...matches]
+ .sort((a, b) => {
+ const rankA = preferred.get(a.id) ?? Number.POSITIVE_INFINITY;
+ const rankB = preferred.get(b.id) ?? Number.POSITIVE_INFINITY;
+ if (rankA !== rankB) return rankA - rankB;
+ return a.name.localeCompare(b.name);
+ })
+ .slice(0, 50);
+ }, [profiles, preferredIds, query]);
+
+ const selected = value
+ ? (profiles.find((profile) => profile.id === value)?.name ??
+ t("common.labels.unknownProfile"))
+ : null;
+
+ return (
+ {
+ setOpen(next);
+ if (!next) setQuery("");
+ }}
+ >
+
+
+
+ {selected ?? t("agent.composer.noProfile")}
+
+
+
+
+
+
+
+
+ {t("agent.composer.profileNotFound")}
+
+ {query.trim().length === 0 && (
+