Compare commits

..

33 Commits

Author SHA1 Message Date
zhom d05ab23404 test: remove https tests 2026-03-16 18:21:01 +04:00
zhom 8511535d69 refactor: socks5 chaining 2026-03-16 17:48:02 +04:00
zhom 29dd5abb34 chore: exclude nightly tag 2026-03-16 15:55:29 +04:00
zhom b2d1456aa9 chore: version bump 2026-03-16 15:50:06 +04:00
zhom e3fc715cfa chore: cp instead of sync 2026-03-16 15:49:25 +04:00
zhom 2cf9013d28 chore: handle download interuptions 2026-03-16 15:48:52 +04:00
zhom 76dd0d84e8 refactor: check proxy validity via donut-proxy 2026-03-16 15:48:00 +04:00
zhom ccecd2a1e3 chore: version bump 2026-03-16 04:44:27 +04:00
zhom 238f7648cf chore: remove ref 2026-03-16 03:34:19 +04:00
zhom c4aee3a00b refactor: encrypt manifest for encrypted profiles 2026-03-16 03:33:44 +04:00
zhom 140e611085 test: e2e for encrypted sync 2026-03-16 02:57:31 +04:00
zhom b4488ee3ec refactor: make bypass of paid plan harder 2026-03-16 02:57:08 +04:00
zhom c4bfd4e253 chore: linting 2026-03-15 20:31:02 +04:00
zhom 0b3dac5da8 chore: icons 2026-03-15 20:06:40 +04:00
zhom db4c1fce6c Merge pull request #236 from zhom/dependabot/cargo/src-tauri/rust-dependencies-f0e0da4c3a
deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
2026-03-15 12:01:28 -04:00
zhom d2d459feeb fix: better scroll handling 2026-03-15 19:58:51 +04:00
zhom 7648785e39 test: run ephemeral dir test serially 2026-03-15 19:00:15 +04:00
dependabot[bot] 081a1922df deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
Bumps the rust-dependencies group with 9 updates in the /src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `7.2.0` | `8.2.0` |
| [rand](https://github.com/rust-random/rand) | `0.9.2` | `0.10.0` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.38.0` | `0.39.0` |
| [smoltcp](https://github.com/smoltcp-rs/smoltcp) | `0.11.0` | `0.12.0` |
| [winreg](https://github.com/gentoo90/winreg-rs) | `0.55.0` | `0.56.0` |
| [resvg](https://github.com/linebender/resvg) | `0.46.0` | `0.47.0` |
| [portable-atomic-util](https://github.com/taiki-e/portable-atomic-util) | `0.2.5` | `0.2.6` |
| [tinyvec](https://github.com/Lokathor/tinyvec) | `1.10.0` | `1.11.0` |
| [uds_windows](https://github.com/haraldh/rust_uds_windows) | `1.2.0` | `1.2.1` |



Updates `zip` from 7.2.0 to 8.2.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v7.2.0...v8.2.0)

Updates `rand` from 0.9.2 to 0.10.0
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.10.0)

Updates `rusqlite` from 0.38.0 to 0.39.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.38.0...v0.39.0)

Updates `smoltcp` from 0.11.0 to 0.12.0
- [Release notes](https://github.com/smoltcp-rs/smoltcp/releases)
- [Changelog](https://github.com/smoltcp-rs/smoltcp/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smoltcp-rs/smoltcp/compare/v0.11.0...v0.12.0)

Updates `winreg` from 0.55.0 to 0.56.0
- [Release notes](https://github.com/gentoo90/winreg-rs/releases)
- [Changelog](https://github.com/gentoo90/winreg-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gentoo90/winreg-rs/compare/v0.55.0...v0.56.0)

Updates `resvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

Updates `libsqlite3-sys` from 0.36.0 to 0.37.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.36.0...v0.37.0)

Updates `portable-atomic-util` from 0.2.5 to 0.2.6
- [Release notes](https://github.com/taiki-e/portable-atomic-util/releases)
- [Changelog](https://github.com/taiki-e/portable-atomic-util/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/portable-atomic-util/compare/v0.2.5...v0.2.6)

Updates `tiny-skia` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tiny-skia-path` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tinyvec` from 1.10.0 to 1.11.0
- [Changelog](https://github.com/Lokathor/tinyvec/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Lokathor/tinyvec/compare/v1.10.0...v1.11.0)

Updates `uds_windows` from 1.2.0 to 1.2.1
- [Release notes](https://github.com/haraldh/rust_uds_windows/releases)
- [Changelog](https://github.com/haraldh/rust_uds_windows/blob/master/CHANGELOG.md)
- [Commits](https://github.com/haraldh/rust_uds_windows/compare/v1.2.0...v1.2.1)

Updates `usvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: rand
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smoltcp
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: winreg
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.37.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: portable-atomic-util
  dependency-version: 0.2.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tiny-skia
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tiny-skia-path
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tinyvec
  dependency-version: 1.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uds_windows
  dependency-version: 1.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: usvg
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 14:38:49 +00:00
zhom 55b8b61f42 fix: run opencode on all issues and prs 2026-03-15 18:00:24 +04:00
zhom 5bea6a32e0 feat: synchronizer 2026-03-15 18:00:04 +04:00
zhom e72874142b Merge pull request #233 from zhom/dependabot/github_actions/github-actions-d7a59ebd9d
ci(deps): bump the github-actions group with 3 updates
2026-03-14 05:05:36 -04:00
dependabot[bot] 6b5b177482 ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `pnpm/action-setup` from 4.2.0 to 4.4.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/41ff72655975bd51cab0327fa583b6e92b6d3061...fc06bc1257f339d1d5d8b3a19a8cae5388b55320)

Updates `anomalyco/opencode` from 1.2.20 to 1.2.26
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/6c7d968c4423a0cd6c85099c9377a6066313fa0a...d954026dd855e018302a6c0733a1dd74140931df)

Updates `swatinem/rust-cache` from 2.8.2 to 2.9.1
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/779680da715d629ac1d338a641029a2f4372abb5...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-14 09:04:26 +00:00
zhom cdaacc5b27 refactor: support non-latin characters 2026-03-14 12:47:15 +04:00
zhom f5e068346c chore: formatting 2026-03-14 12:47:02 +04:00
zhom 07ac2b7ff8 chore: linting 2026-03-14 12:46:34 +04:00
zhom ee7160bb9e chore: update dependencies 2026-03-14 12:36:43 +04:00
zhom d0ea3f8903 refactor: match API spec in MCP 2026-03-14 12:31:34 +04:00
zhom 942d193206 feat: human-like typing for MCP 2026-03-14 12:12:14 +04:00
zhom 90563ea6f5 refactor: allow use without external sleep 2026-03-14 11:29:13 +04:00
zhom 6a88887a6c docs: agents 2026-03-14 08:51:00 +04:00
zhom 0553f76f71 chore: linting 2026-03-13 12:57:01 +04:00
zhom 95e5dbb84a chore: use env for aws instead of configure 2026-03-13 10:20:08 +04:00
zhom e9b5442340 refactor: cleanup 2026-03-13 10:19:34 +04:00
113 changed files with 9665 additions and 4699 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+8 -5
View File
@@ -3,7 +3,7 @@ name: Issue & PR Automation
on:
issues:
types: [opened]
pull_request:
pull_request_target:
types: [opened]
issue_comment:
types: [created]
@@ -42,9 +42,10 @@ jobs:
fi
- name: Analyze issue
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
prompt: |
@@ -66,7 +67,7 @@ jobs:
- Never exceed 6 items total.
analyze-pr:
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
if: github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -91,9 +92,10 @@ jobs:
fi
- name: Analyze PR
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
prompt: |
@@ -127,8 +129,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Run opencode
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+25 -22
View File
@@ -102,7 +102,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -125,7 +125,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workdir: ./src-tauri
@@ -264,22 +264,19 @@ jobs:
go install github.com/ralt/repogen/cmd/repogen@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Configure AWS CLI for Cloudflare R2
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
- name: Sync existing repo metadata from R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
mkdir -p /tmp/repo
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
- name: Generate repository with repogen
run: |
@@ -296,24 +293,30 @@ jobs:
- name: Upload repository to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 cp /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}" --recursive
- name: Verify upload
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "DEB repo:"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
echo "RPM repo:"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
+2 -2
View File
@@ -101,7 +101,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -124,7 +124,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workdir: ./src-tauri
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -51,7 +51,7 @@ jobs:
toolchain: stable
- name: Cache Rust dependencies
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workspaces: "src-tauri"
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+10
View File
@@ -0,0 +1,10 @@
# Prevent pushing the 'nightly' tag — it is managed by CI
if git rev-parse nightly >/dev/null 2>&1; then
LOCAL_NIGHTLY=$(git rev-parse nightly)
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
# Delete the local nightly tag so --tags won't try to push it
git tag -d nightly >/dev/null 2>&1 || true
fi
fi
+13
View File
@@ -13,6 +13,7 @@
"autoconfig",
"autologin",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"busctl",
@@ -42,6 +43,7 @@
"DBAPI",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"distro",
@@ -83,6 +85,7 @@
"infobars",
"inkey",
"Inno",
"isps",
"kdeglobals",
"keras",
"KHTML",
@@ -131,6 +134,7 @@
"ntlm",
"numpy",
"objc",
"oneshot",
"opencode",
"orhun",
"orjson",
@@ -163,12 +167,16 @@
"pyyaml",
"quic",
"ralt",
"ramdisk",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"SARIF",
@@ -188,6 +196,7 @@
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
@@ -208,14 +217,18 @@
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
"tmpfs",
"tqdm",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
"udeps",
"unlisten",
"unminimize",
+37 -7
View File
@@ -1,9 +1,39 @@
# Instructions for AI Agents
# Project Guidelines
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
- Don't leave comments that don't add value.
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
## Testing and Quality
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
## Code Quality
- Don't leave comments that don't add value
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
## UI Theming
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+20 -1
View File
@@ -4,6 +4,7 @@
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
## Code Quality
@@ -17,4 +18,22 @@
## UI Theming
- When modifying the UI, don't add random colors that are not controlled by `src/lib/themes.ts`
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+1
View File
@@ -9,3 +9,4 @@ extend-exclude = [
[default.extend-words]
DBE = "DBE"
nd = "nd"
+4 -4
View File
@@ -18,8 +18,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1004.0",
"@aws-sdk/s3-request-presigner": "^3.1004.0",
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.0",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.16",
@@ -35,9 +35,9 @@
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.5",
"@types/node": "^25.5.0",
"@types/supertest": "^7.2.0",
"jest": "^30.2.0",
"jest": "^30.3.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
+13 -9
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.16.1",
"version": "0.17.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -12,9 +12,10 @@
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
@@ -56,15 +57,15 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.8.14",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"motion": "^12.35.0",
"motion": "^12.36.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.6",
"react-i18next": "^16.5.8",
"react-icons": "^5.6.0",
"recharts": "3.8.0",
"sonner": "^2.0.7",
@@ -72,16 +73,16 @@
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.6",
"@biomejs/biome": "2.4.7",
"@tailwindcss/postcss": "^4.2.1",
"@tauri-apps/cli": "~2.10.1",
"@types/color": "^4.2.0",
"@types/node": "^25.3.5",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"lint-staged": "^16.3.4",
"tailwindcss": "^4.2.1",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
@@ -96,6 +97,9 @@
"bash -c 'cd src-tauri && cargo fmt --all'",
"bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all'",
"bash -c 'cd src-tauri && cargo test --lib'"
],
"**/*.{rs,ts,tsx,js,jsx,md}": [
"typos"
]
}
}
+1146 -1205
View File
File diff suppressed because it is too large Load Diff
+505 -251
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.16.1"
version = "0.17.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -30,7 +30,7 @@ path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.46"
resvg = "0.47"
[dependencies]
serde_json = "1"
@@ -57,7 +57,7 @@ base64 = "0.22"
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
zip = { version = "7", default-features = false, features = ["deflate-flate2"] }
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
tar = "0"
bzip2 = "0"
flate2 = "1"
@@ -76,11 +76,15 @@ chrono-tz = "0.10"
axum = { version = "0.8.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
rand = "0.10.0"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -92,7 +96,7 @@ playwright = { git = "https://github.com/sctg-development/playwright-rust", bran
# Wayfern CDP integration
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
rusqlite = { version = "0.38", features = ["bundled"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9"
thiserror = "2.0"
regex-lite = "0.1"
@@ -101,9 +105,8 @@ maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
lz4_flex = "0.11"
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.21"
@@ -123,7 +126,7 @@ objc2 = "0.6.3"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
winreg = "0.56"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+62 -24
View File
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -1028,6 +1034,8 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
dynamic_proxy_url: p.dynamic_proxy_url,
dynamic_proxy_format: p.dynamic_proxy_format,
proxy_settings: p.proxy_settings,
})
.collect(),
@@ -1061,6 +1069,8 @@ async fn get_proxy(
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
}))
} else {
Err(StatusCode::NOT_FOUND)
@@ -1086,14 +1096,27 @@ async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
) {
let result = if let (Some(url), Some(format)) =
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
{
PROXY_MANAGER.create_dynamic_proxy(
&state.app_handle,
request.name.clone(),
url.clone(),
format.clone(),
)
} else if let Some(settings) = request.proxy_settings {
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
} else {
return Err(StatusCode::BAD_REQUEST);
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
@@ -1124,28 +1147,29 @@ async fn update_proxy(
State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
let new_name = request.name.unwrap_or(proxy.name.clone());
let new_proxy_settings = request
.proxy_settings
.unwrap_or(proxy.proxy_settings.clone());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
match PROXY_MANAGER.update_stored_proxy(
let result = if is_dynamic {
PROXY_MANAGER.update_dynamic_proxy(
&state.app_handle,
&id,
Some(new_name.clone()),
Some(new_proxy_settings.clone()),
) {
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
request.name,
request.dynamic_proxy_url,
request.dynamic_proxy_format,
)
} else {
Err(StatusCode::NOT_FOUND)
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
@@ -1289,6 +1313,13 @@ async fn run_profile(
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let headless = request.headless.unwrap_or(false);
let url = request.url;
@@ -1357,6 +1388,13 @@ async fn open_url_in_profile(
State(state): State<ApiServerState>,
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
+1 -29
View File
@@ -106,35 +106,7 @@ impl AutoUpdater {
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there's a significant version jump
// Compare the major version component (first number before the dot)
let current_major: u32 = profile
.version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let new_major: u32 = update
.new_version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let result = new_major.saturating_sub(current_major);
log::info!(
"Current major version: {current_major}, New major version: {new_major}, Diff: {result}"
);
if result > 0 {
notifications.push(update);
} else {
log::info!("Skipping chromium update notification: same major version");
}
} else {
notifications.push(update);
}
notifications.push(update);
}
}
}
+77 -554
View File
@@ -13,11 +13,6 @@ pub struct ProxySettings {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum BrowserType {
Chromium,
Firefox,
FirefoxDeveloper,
Brave,
Zen,
Camoufox,
Wayfern,
}
@@ -25,11 +20,6 @@ pub enum BrowserType {
impl BrowserType {
pub fn as_str(&self) -> &'static str {
match self {
BrowserType::Chromium => "chromium",
BrowserType::Firefox => "firefox",
BrowserType::FirefoxDeveloper => "firefox-developer",
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::Camoufox => "camoufox",
BrowserType::Wayfern => "wayfern",
}
@@ -37,11 +27,6 @@ impl BrowserType {
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"chromium" => Ok(BrowserType::Chromium),
"firefox" => Ok(BrowserType::Firefox),
"firefox-developer" => Ok(BrowserType::FirefoxDeveloper),
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"camoufox" => Ok(BrowserType::Camoufox),
"wayfern" => Ok(BrowserType::Wayfern),
_ => Err(format!("Unknown browser type: {s}")),
@@ -49,6 +34,7 @@ impl BrowserType {
}
}
#[allow(dead_code)]
pub trait Browser: Send + Sync {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
@@ -88,10 +74,7 @@ mod macos {
.filter(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("Browser")
name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("Browser")
})
.map(|entry| entry.path())
.collect();
@@ -200,34 +183,6 @@ mod macos {
Ok(executable_path)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
pub fn get_wayfern_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -281,18 +236,7 @@ mod macos {
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On macOS, no special preparation needed
Ok(())
@@ -312,24 +256,10 @@ mod linux {
// - Firefox/Firefox Developer on Linux often extract to: install_dir/firefox/firefox
// - Some archives may extract directly under: install_dir/firefox or install_dir/firefox-bin
// - For some flavors we may have: install_dir/<browser_type>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
let _browser_subdir = install_dir.join(browser_type.as_str());
// Try common firefox executable locations (nested and flat)
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => vec![
// Nested "firefox/firefox" or "firefox/firefox-bin"
install_dir.join("firefox").join("firefox"),
install_dir.join("firefox").join("firefox-bin"),
// Flat under version directory
install_dir.join("firefox"),
install_dir.join("firefox-bin"),
// Under a subdirectory matching the browser type
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
],
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -360,36 +290,10 @@ mod linux {
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths (tar.xz may extract to a subdirectory)
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -418,22 +322,9 @@ mod linux {
// install_dir/<browser>/<binary>
// However, Firefox Developer tarballs often extract to a "firefox" subfolder
// rather than "firefox-developer". Support both layouts.
let browser_subdir = install_dir.join(browser_type.as_str());
let _browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
// Preferred: executable inside a subdirectory named after the browser type
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
// Fallback: executable inside a generic "firefox" subdirectory
install_dir.join("firefox").join("firefox-bin"),
install_dir.join("firefox").join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -454,36 +345,10 @@ mod linux {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -500,6 +365,7 @@ mod linux {
false
}
#[allow(dead_code)]
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
log::info!("Setting execute permissions for: {:?}", executable_path);
@@ -551,10 +417,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return Ok(path);
}
@@ -571,30 +434,11 @@ mod windows {
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for .exe files
let possible_paths = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -618,18 +462,14 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return Ok(path);
}
}
}
}
Err("Chromium/Brave/Wayfern executable not found in Windows installation directory".into())
Err("Chromium/Wayfern executable not found in Windows installation directory".into())
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
@@ -657,10 +497,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return true;
}
@@ -674,30 +511,11 @@ mod windows {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// On Windows, check for .exe files
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -722,11 +540,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return true;
}
}
@@ -736,236 +550,13 @@ mod windows {
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Windows, no special preparation needed
Ok(())
}
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec!["-profile".to_string(), profile_path.to_string()];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Use -no-remote when remote debugging to avoid conflicts with existing instances
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Firefox browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
impl ChromiumBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for ChromiumBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
format!("--user-data-dir={}", profile_path),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
// Disable QUIC/HTTP3 to ensure traffic goes through HTTP proxy
"--disable-quic".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--remote-debugging-address=0.0.0.0".to_string());
args.push(format!("--remote-debugging-port={port}"));
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Chromium browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
pub struct CamoufoxBrowser;
impl CamoufoxBrowser {
@@ -1175,10 +766,6 @@ impl BrowserFactory {
pub fn create_browser(&self, browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
Box::new(FirefoxBrowser::new(browser_type))
}
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
BrowserType::Wayfern => Box::new(WayfernBrowser::new()),
}
@@ -1272,35 +859,10 @@ mod tests {
#[test]
fn test_browser_type_conversions() {
// Test as_str
assert_eq!(BrowserType::Firefox.as_str(), "firefox");
assert_eq!(BrowserType::FirefoxDeveloper.as_str(), "firefox-developer");
assert_eq!(BrowserType::Chromium.as_str(), "chromium");
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
assert_eq!(BrowserType::Wayfern.as_str(), "wayfern");
// Test from_str - use expect with descriptive messages instead of unwrap
assert_eq!(
BrowserType::from_str("firefox").expect("firefox should be valid"),
BrowserType::Firefox
);
assert_eq!(
BrowserType::from_str("firefox-developer").expect("firefox-developer should be valid"),
BrowserType::FirefoxDeveloper
);
assert_eq!(
BrowserType::from_str("chromium").expect("chromium should be valid"),
BrowserType::Chromium
);
assert_eq!(
BrowserType::from_str("brave").expect("brave should be valid"),
BrowserType::Brave
);
assert_eq!(
BrowserType::from_str("zen").expect("zen should be valid"),
BrowserType::Zen
);
// Test from_str
assert_eq!(
BrowserType::from_str("camoufox").expect("camoufox should be valid"),
BrowserType::Camoufox
@@ -1320,25 +882,25 @@ mod tests {
let empty_result = BrowserType::from_str("");
assert!(empty_result.is_err(), "Empty string should return error");
let case_sensitive_result = BrowserType::from_str("Firefox");
assert!(
case_sensitive_result.is_err(),
"Case sensitive check should fail"
BrowserType::from_str("firefox").is_err(),
"Removed browser types should return error"
);
assert!(
BrowserType::from_str("chromium").is_err(),
"Removed browser types should return error"
);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
fn test_camoufox_launch_args() {
let browser = CamoufoxBrowser::new();
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Firefox");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Firefox should not use -no-remote for normal launch"
);
.expect("Failed to create launch args for Camoufox");
assert!(args.contains(&"-profile".to_string()));
assert!(args.contains(&"/path/to/profile".to_string()));
assert!(args.contains(&"-no-remote".to_string()));
let args = browser
.create_launch_args(
@@ -1348,40 +910,20 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Firefox with URL");
assert_eq!(
args,
vec!["-profile", "/path/to/profile", "https://example.com"]
);
.expect("Failed to create launch args for Camoufox with URL");
assert!(args.contains(&"https://example.com".to_string()));
// Test Firefox with remote debugging (should use -no-remote)
// Test with remote debugging
let args = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Firefox with remote debugging");
assert!(
args.contains(&"-no-remote".to_string()),
"Firefox should use -no-remote for remote debugging"
);
assert!(
args.contains(&"--start-debugger-server".to_string()),
"Firefox should include debugger server arg"
);
assert!(
args.contains(&"9222".to_string()),
"Firefox should include debugging port"
);
// Test Zen Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
.expect("Failed to create launch args for Camoufox with remote debugging");
assert!(args.contains(&"--start-debugger-server".to_string()));
assert!(args.contains(&"9222".to_string()));
// Test headless mode
let args = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Zen Browser headless");
.expect("Failed to create launch args for Camoufox headless");
assert!(
args.contains(&"--headless".to_string()),
"Browser should include headless flag when requested"
@@ -1389,30 +931,27 @@ mod tests {
}
#[test]
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
fn test_wayfern_launch_args() {
let browser = WayfernBrowser::new();
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Chromium");
.expect("Failed to create launch args for Wayfern");
// Test that basic required arguments are present
assert!(
args.contains(&"--user-data-dir=/path/to/profile".to_string()),
"Chromium args should contain user-data-dir"
"Wayfern args should contain user-data-dir"
);
assert!(
args.contains(&"--no-default-browser-check".to_string()),
"Chromium args should contain no-default-browser-check"
"Wayfern args should contain no-default-browser-check"
);
// Test that automatic update disabling arguments are present
assert!(
args.contains(&"--disable-background-mode".to_string()),
"Chromium args should contain disable-background-mode"
"Wayfern args should contain disable-background-mode"
);
assert!(
args.contains(&"--disable-component-update".to_string()),
"Chromium args should contain disable-component-update"
"Wayfern args should contain disable-component-update"
);
let args_with_url = browser
@@ -1423,13 +962,11 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Chromium with URL");
.expect("Failed to create launch args for Wayfern with URL");
assert!(
args_with_url.contains(&"https://example.com".to_string()),
"Chromium args should contain the URL"
"Wayfern args should contain the URL"
);
// Verify URL is at the end
assert_eq!(
args_with_url.last().expect("Args should not be empty"),
"https://example.com"
@@ -1438,23 +975,19 @@ mod tests {
// Test remote debugging
let args_with_debug = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Chromium with remote debugging");
.expect("Failed to create launch args for Wayfern with remote debugging");
assert!(
args_with_debug.contains(&"--remote-debugging-port=9222".to_string()),
"Chromium args should contain remote debugging port"
);
assert!(
args_with_debug.contains(&"--remote-debugging-address=0.0.0.0".to_string()),
"Chromium args should contain remote debugging address"
"Wayfern args should contain remote debugging port"
);
// Test headless mode
let args_headless = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Chromium headless");
.expect("Failed to create launch args for Wayfern headless");
assert!(
args_headless.contains(&"--headless".to_string()),
"Chromium args should contain headless flag when requested"
args_headless.contains(&"--headless=new".to_string()),
"Wayfern args should contain headless flag when requested"
);
}
@@ -1491,26 +1024,21 @@ mod tests {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
// Create a mock Camoufox browser installation
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
#[cfg(target_os = "macos")]
{
// Create a mock .app directory for macOS
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Firefox.app directory");
let app_dir = browser_dir.join("Camoufox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Camoufox.app directory");
}
#[cfg(target_os = "linux")]
{
// Create a mock firefox subdirectory and executable for Linux
let firefox_subdir = browser_dir.join("firefox");
fs::create_dir_all(&firefox_subdir).expect("Failed to create firefox subdirectory");
let executable_path = firefox_subdir.join("firefox");
let executable_path = browser_dir.join("camoufox");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
@@ -1523,67 +1051,62 @@ mod tests {
#[cfg(target_os = "windows")]
{
// Create a mock firefox.exe for Windows
let executable_path = browser_dir.join("firefox.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
}
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
let browser = CamoufoxBrowser::new();
assert!(browser.is_version_downloaded("135.0.1", binaries_dir));
assert!(!browser.is_version_downloaded("999.0", binaries_dir));
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).expect("Failed to create chromium directory");
// Test with Wayfern browser
let wayfern_dir = binaries_dir.join("wayfern").join("1.0.0");
fs::create_dir_all(&wayfern_dir).expect("Failed to create wayfern directory");
#[cfg(target_os = "macos")]
{
let chromium_app_dir = chromium_dir.join("Chromium.app");
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS"))
let wayfern_app_dir = wayfern_dir.join("Chromium.app");
fs::create_dir_all(wayfern_app_dir.join("Contents").join("MacOS"))
.expect("Failed to create Chromium.app structure");
// Create a mock executable
let executable_path = chromium_app_dir
let executable_path = wayfern_app_dir
.join("Contents")
.join("MacOS")
.join("Chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock Chromium executable");
.expect("Failed to write mock Wayfern executable");
}
#[cfg(target_os = "linux")]
{
// Create a mock chromium executable for Linux
let executable_path = chromium_dir.join("chromium");
let executable_path = wayfern_dir.join("chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock chromium executable");
.expect("Failed to write mock wayfern executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
.expect("Failed to get chromium metadata")
.expect("Failed to get wayfern metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable_path, permissions)
.expect("Failed to set chromium permissions");
.expect("Failed to set wayfern permissions");
}
#[cfg(target_os = "windows")]
{
// Create a mock chromium.exe for Windows
let executable_path = chromium_dir.join("chromium.exe");
let executable_path = wayfern_dir.join("chromium.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock chromium.exe");
}
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
let wayfern_browser = WayfernBrowser::new();
assert!(
chromium_browser.is_version_downloaded("1465660", binaries_dir),
"Chromium version should be detected as downloaded"
wayfern_browser.is_version_downloaded("1.0.0", binaries_dir),
"Wayfern version should be detected as downloaded"
);
assert!(
!chromium_browser.is_version_downloaded("1465661", binaries_dir),
"Non-existent Chromium version should not be detected as downloaded"
!wayfern_browser.is_version_downloaded("9.9.9", binaries_dir),
"Non-existent Wayfern version should not be detected as downloaded"
);
}
@@ -1593,28 +1116,28 @@ mod tests {
let binaries_dir = temp_dir.path();
// Create browser directory but no proper executable structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
// Create some other files but no proper executable structure
fs::write(browser_dir.join("readme.txt"), "Some content").expect("Failed to write readme file");
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let browser = CamoufoxBrowser::new();
assert!(
!browser.is_version_downloaded("139.0", binaries_dir),
"Firefox version should not be detected without proper executable structure"
!browser.is_version_downloaded("135.0.1", binaries_dir),
"Camoufox version should not be detected without proper executable structure"
);
}
#[test]
fn test_browser_type_clone_and_debug() {
let browser_type = BrowserType::Firefox;
let browser_type = BrowserType::Camoufox;
let cloned = browser_type.clone();
assert_eq!(browser_type, cloned);
// Test Debug trait
let debug_str = format!("{browser_type:?}");
assert!(debug_str.contains("Firefox"));
assert!(debug_str.contains("Camoufox"));
}
#[test]
+48 -412
View File
@@ -1,4 +1,4 @@
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::browser::ProxySettings;
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -40,12 +40,25 @@ impl BrowserRunner {
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
async fn resolve_proxy_with_refresh(
&self,
proxy_id: Option<&String>,
profile_id: Option<&str>,
) -> Option<ProxySettings> {
let proxy_id = proxy_id?;
) -> Result<Option<ProxySettings>, String> {
let proxy_id = match proxy_id {
Some(id) => id,
None => return Ok(None),
};
// Handle dynamic proxies: fetch from URL at launch time
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
return Ok(Some(settings));
}
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await;
@@ -53,10 +66,10 @@ impl BrowserRunner {
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
if let Some(pid) = profile_id {
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid);
return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid));
}
}
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
/// Get the executable path for a browser profile
@@ -98,9 +111,9 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
_local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
_headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
@@ -117,7 +130,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -375,7 +389,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -613,248 +628,12 @@ impl BrowserRunner {
return Ok(updated_profile);
}
// Create browser instance
let browser_type = BrowserType::from_str(&profile.browser)
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone());
// Get executable path using common helper
let executable_path = self
.get_browser_executable_path(profile)
.expect("Failed to get executable path");
log::info!("Executable path: {executable_path:?}");
// Prepare the executable (set permissions, etc.)
if let Err(e) = browser.prepare_executable(&executable_path) {
log::warn!("Warning: Failed to prepare executable: {e}");
// Continue anyway, the error might not be critical
}
// Refresh cloud proxy credentials if needed before resolving
let _stored_proxy_settings = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
// Use provided local proxy for Chromium-based browsers launch arguments
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
// Get profile data path and launch arguments
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let browser_args = browser
.create_launch_args(
&profile_data_path.to_string_lossy(),
proxy_for_launch_args,
url,
remote_debugging_port,
headless,
)
.expect("Failed to create launch arguments");
// Launch browser using platform-specific method
let child = {
#[cfg(target_os = "macos")]
{
platform_browser::macos::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "windows")]
{
platform_browser::windows::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "linux")]
{
platform_browser::linux::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return Err("Unsupported platform for browser launching".into());
}
};
let launcher_pid = child.id();
log::info!(
"Launched browser with launcher PID: {} for profile: {} (ID: {})",
launcher_pid,
profile.name,
profile.id
);
// On macOS, when launching via `open -a`, the child PID is the `open` helper.
// Resolve and store the actual browser PID for all browser types.
let actual_pid = {
#[cfg(target_os = "macos")]
{
// Give the browser a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
let system = System::new_all();
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path_str = profile_data_path.to_string_lossy();
let mut resolved_pid = launcher_pid;
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
// Determine if this process matches the intended browser type
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name_lower.contains("firefox")
&& !exe_name_lower.contains("developer")
&& !exe_name_lower.contains("camoufox")
}
"firefox-developer" => {
// More flexible detection for Firefox Developer Edition
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|| (exe_name_lower.contains("firefox")
&& cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Developer")
|| arg_str.contains("developer")
|| arg_str.contains("FirefoxDeveloperEdition")
|| arg_str.contains("firefox-developer")
}))
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
}
"zen" => exe_name_lower.contains("zen"),
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
_ => false,
};
if !is_correct_browser {
continue;
}
// Check for profile path match
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
if next_arg == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
// Also check for combined -profile=path format
if arg_str == format!("-profile={profile_data_path_str}") {
found_profile_arg = true;
break;
}
// Check if the argument is the profile path directly
if arg_str == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
found_profile_arg
} else {
// Chromium-based browsers: look for --user-data-dir argument
cmd.iter().any(|s| {
if let Some(arg) = s.to_str() {
arg == format!("--user-data-dir={profile_data_path_str}")
|| arg == profile_data_path_str
} else {
false
}
})
};
if profile_path_match {
let pid_u32 = pid.as_u32();
if pid_u32 != launcher_pid {
resolved_pid = pid_u32;
break;
}
}
}
resolved_pid
}
#[cfg(not(target_os = "macos"))]
{
launcher_pid
}
};
// Update profile with process info and save
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(actual_pid);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
self.save_process_info(&updated_profile)?;
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default());
});
// Apply proxy settings if needed (for Firefox-based browsers)
if profile.proxy_id.is_some()
&& matches!(
browser_type,
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen
)
{
// Proxy settings for Firefox-based browsers are applied via user.js file
// which is already handled in the profile creation process
}
log::info!(
"Emitting profile events for successful launch: {} (ID: {})",
updated_profile.name,
updated_profile.id
);
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
// Emit minimal running changed event to frontend with a small delay to ensure UI consistency
#[derive(Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: updated_profile.id.to_string(),
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
"Successfully emitted profile-running-changed event for {}: running={}",
updated_profile.name,
payload.is_running
);
}
Ok(updated_profile)
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn open_url_in_existing_browser(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: &str,
_internal_proxy_settings: Option<&ProxySettings>,
@@ -948,134 +727,7 @@ impl BrowserRunner {
}
}
// Use the comprehensive browser status check for non-camoufox/wayfern browsers
let is_running = self
.check_browser_status(app_handle.clone(), profile)
.await?;
if !is_running {
return Err("Browser is not running".into());
}
// Get the updated profile with current PID
let profiles = self
.profile_manager
.list_profiles()
.expect("Failed to list profiles");
let updated_profile = profiles
.into_iter()
.find(|p| p.id == profile.id)
.unwrap_or_else(|| profile.clone());
// Ensure we have a valid process ID
if updated_profile.process_id.is_none() {
return Err("No valid process ID found for the browser".into());
}
let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version);
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// Camoufox URL opening is handled differently
Err("URL opening in existing Camoufox instance is not supported".into())
}
BrowserType::Wayfern => {
// Wayfern URL opening is handled differently
Err("URL opening in existing Wayfern instance is not supported".into())
}
BrowserType::Chromium | BrowserType::Brave => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
}
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn launch_browser_with_debugging(
@@ -1091,7 +743,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials before resolving
let upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
@@ -1115,32 +768,6 @@ impl BrowserRunner {
let internal_proxy_settings = Some(internal_proxy.clone());
// Configure Firefox profiles to use local proxy
{
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
self
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
let result = self
.launch_browser_internal(
app_handle.clone(),
@@ -2530,11 +2157,14 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
@@ -2566,11 +2196,14 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
@@ -2620,7 +2253,7 @@ pub async fn launch_browser_profile(
profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()),
)
.await;
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -2883,11 +2516,14 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Converts fingerprints to Camoufox configuration format and builds launch options.
use rand::Rng;
use rand::RngExt;
use serde_yaml;
use std::collections::HashMap;
use std::path::Path;
@@ -2,7 +2,7 @@
//!
//! Implements weighted random sampling from conditional probability distributions.
use rand::Rng;
use rand::RngExt;
use serde::Deserialize;
use std::collections::HashMap;
+1 -1
View File
@@ -9,7 +9,7 @@ use directories::BaseDirs;
use maxminddb::{geoip2, Reader};
use quick_xml::events::Event;
use quick_xml::Reader as XmlReader;
use rand::Rng;
use rand::RngExt;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Samples realistic WebGL configurations based on OS-specific probability distributions.
use rand::Rng;
use rand::RngExt;
use rusqlite::{Connection, Result as SqliteResult};
use std::collections::HashMap;
use std::io::Write;
+60 -79
View File
@@ -56,6 +56,7 @@ pub struct CamoufoxLaunchResult {
#[serde(alias = "profile_path")]
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
}
#[derive(Debug)]
@@ -65,6 +66,7 @@ struct CamoufoxInstance {
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
}
struct CamoufoxManagerInner {
@@ -88,6 +90,33 @@ impl CamoufoxManager {
&CAMOUFOX_LAUNCHER
}
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
#[allow(dead_code)]
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub fn get_profiles_dir(&self) -> PathBuf {
crate::app_dirs::profiles_dir()
}
@@ -239,6 +268,9 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
if let Some(url) = url {
args.push("-new-tab".to_string());
@@ -294,6 +326,7 @@ impl CamoufoxManager {
process_id,
profile_path: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
let launch_result = CamoufoxLaunchResult {
@@ -301,6 +334,7 @@ impl CamoufoxManager {
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
{
@@ -418,6 +452,7 @@ impl CamoufoxManager {
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
}));
}
}
@@ -428,7 +463,9 @@ impl CamoufoxManager {
// If not found in in-memory instances, scan system processes
// This handles the case where the app was restarted but Camoufox is still running
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
if let Some((pid, found_profile_path, cdp_port)) =
self.find_camoufox_process_by_profile(&target_path)
{
log::info!(
"Found running Camoufox process (PID: {}) for profile path via system scan",
pid
@@ -444,6 +481,7 @@ impl CamoufoxManager {
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
@@ -452,6 +490,7 @@ impl CamoufoxManager {
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
}));
}
@@ -462,7 +501,7 @@ impl CamoufoxManager {
fn find_camoufox_process_by_profile(
&self,
target_path: &std::path::Path,
) -> Option<(u32, String)> {
) -> Option<(u32, String, Option<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
@@ -487,6 +526,10 @@ impl CamoufoxManager {
continue;
}
let mut matched = false;
let mut found_profile_path = None;
let mut cdp_port: Option<u16> = None;
// Check if the command line contains our profile path
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
@@ -498,15 +541,27 @@ impl CamoufoxManager {
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
if cmd_path == target_path {
return Some((pid.as_u32(), next_arg.to_string()));
matched = true;
found_profile_path = Some(next_arg.to_string());
}
}
}
// Also check if the argument contains the profile path directly
if arg_str.contains(&*target_path_str) {
return Some((pid.as_u32(), target_path_str.to_string()));
if !matched && arg_str.contains(&*target_path_str) {
matched = true;
found_profile_path = Some(target_path_str.to_string());
}
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
cdp_port = port_val.parse().ok();
}
}
}
if matched {
if let Some(profile_path) = found_profile_path {
return Some((pid.as_u32(), profile_path, cdp_port));
}
}
}
@@ -630,9 +685,6 @@ impl CamoufoxManager {
}
}
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
write_default_search_config(&profile_path);
self
.launch_camoufox(
&app_handle,
@@ -646,77 +698,6 @@ impl CamoufoxManager {
}
}
fn write_default_search_config(profile_path: &std::path::Path) {
let search_file = profile_path.join("search.json.mozlz4");
if search_file.exists() {
return;
}
let json = serde_json::json!({
"version": 6,
"engines": [
{
"_name": "DuckDuckGo",
"_isAppProvided": false,
"_metaData": { "order": 1 },
"_urls": [
{
"template": "https://duckduckgo.com/?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://duckduckgo.com/favicon.ico"
},
{
"_name": "Google",
"_isAppProvided": false,
"_metaData": { "order": 2 },
"_urls": [
{
"template": "https://www.google.com/search?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://www.google.com/favicon.ico"
}
],
"metaData": {
"useSavedOrder": false,
"defaultEngineId": "DuckDuckGo"
}
});
let json_bytes = match serde_json::to_vec(&json) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("Failed to serialize search config: {e}");
return;
}
};
let magic = b"mozLz40\0";
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
let mut output = Vec::with_capacity(magic.len() + compressed.len());
output.extend_from_slice(magic);
output.extend_from_slice(&compressed);
if let Err(e) = std::fs::write(&search_file, &output) {
log::warn!("Failed to write search.json.mozlz4: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
+3 -3
View File
@@ -1080,8 +1080,8 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop)
if wayfern_refresh_counter >= 72 {
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 60 {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
@@ -1390,7 +1390,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
+186 -25
View File
@@ -7,6 +7,112 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::AppHandle;
/// Chromium cookie encryption/decryption support.
/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC.
/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC.
mod chrome_decrypt {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
const PBKDF2_ITERATIONS: u32 = 1;
const KEY_LEN: usize = 16; // AES-128
const SALT: &[u8] = b"saltysalt";
const IV: [u8; 16] = [b' '; 16]; // 16 spaces
fn derive_key(password: &[u8]) -> [u8; KEY_LEN] {
let mut key = [0u8; KEY_LEN];
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, SALT, PBKDF2_ITERATIONS, &mut key);
key
}
/// Get the encryption key for Chrome cookies.
/// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms.
/// On macOS/Linux the key is a base64 string used as PBKDF2 password.
/// On Windows the key is raw bytes (32 bytes) used directly.
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
let key_file = profile_data_path.join("os_crypt_key");
if let Ok(contents) = std::fs::read_to_string(&key_file) {
let contents = contents.trim();
if !contents.is_empty() {
return Some(derive_key(contents.as_bytes()));
}
}
// Fallback for macOS: try system Keychain (for profiles created before file-based keys)
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("security")
.args([
"find-generic-password",
"-w",
"-s",
"Chromium Safe Storage",
"-a",
"Chromium",
])
.output()
.ok()?;
if output.status.success() {
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !password.is_empty() {
return Some(derive_key(password.as_bytes()));
}
}
}
None
}
/// Decrypt a Chrome encrypted cookie value.
/// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux).
pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option<String> {
if encrypted.len() < 3 {
return None;
}
// Check for v10/v11 prefix
let prefix = &encrypted[..3];
if prefix != b"v10" && prefix != b"v11" {
return None;
}
let ciphertext = &encrypted[3..];
if ciphertext.is_empty() {
return Some(String::new());
}
let mut buf = ciphertext.to_vec();
let decrypted = Aes128CbcDec::new(key.into(), &IV.into())
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.ok()?;
String::from_utf8(decrypted.to_vec()).ok()
}
/// Encrypt a cookie value in Chrome format (v10/v11 prefix + AES-128-CBC).
pub fn encrypt(plaintext: &str, key: &[u8; KEY_LEN]) -> Vec<u8> {
let pt = plaintext.as_bytes();
let block_size = 16usize;
// Allocate buffer with space for PKCS7 padding (up to one extra block)
let padded_len = pt.len() + (block_size - pt.len() % block_size);
let mut buf = vec![0u8; padded_len];
buf[..pt.len()].copy_from_slice(pt);
let encrypted = Aes128CbcEnc::new(key.into(), &IV.into())
.encrypt_padded_mut::<Pkcs7>(&mut buf, pt.len())
.expect("encryption buffer too small");
let mut result = Vec::with_capacity(3 + encrypted.len());
#[cfg(target_os = "macos")]
result.extend_from_slice(b"v10");
#[cfg(not(target_os = "macos"))]
result.extend_from_slice(b"v11");
result.extend_from_slice(encrypted);
result
}
}
/// Unified cookie representation that works across both browser types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedCookie {
@@ -77,6 +183,12 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
/// Get the Chrome cookie encryption key for a Wayfern profile
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
chrome_decrypt::get_encryption_key(&profile_data_path)
}
/// Get the cookie database path for a profile
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
@@ -155,31 +267,58 @@ impl CookieManager {
Ok(cookies)
}
/// Read cookies from a Chrome/Wayfern profile
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
/// Read cookies from a Chrome/Wayfern profile.
/// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key.
fn read_chrome_cookies(
db_path: &Path,
encryption_key: Option<&[u8; 16]>,
) -> Result<Vec<UnifiedCookie>, String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT name, value, host_key, path, expires_utc, is_secure,
is_httponly, samesite, creation_utc, last_access_utc
FROM cookies",
is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
FROM cookies",
)
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
let cookies = stmt
.query_map([], |row| {
let name: String = row.get(0)?;
let plaintext_value: String = row.get(1)?;
let domain: String = row.get(2)?;
let path: String = row.get(3)?;
let expires_utc: i64 = row.get(4)?;
let is_secure: i32 = row.get(5)?;
let is_httponly: i32 = row.get(6)?;
let samesite: i32 = row.get(7)?;
let creation_utc: i64 = row.get(8)?;
let last_access_utc: i64 = row.get(9)?;
let encrypted_value: Vec<u8> = row.get(10)?;
// Use plaintext value if available, otherwise decrypt encrypted_value
let value = if !plaintext_value.is_empty() {
plaintext_value
} else if !encrypted_value.is_empty() {
encryption_key
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key))
.unwrap_or_default()
} else {
String::new()
};
Ok(UnifiedCookie {
name: row.get(0)?,
value: row.get(1)?,
domain: row.get(2)?,
path: row.get(3)?,
expires: Self::chrome_time_to_unix(row.get(4)?),
is_secure: row.get::<_, i32>(5)? != 0,
is_http_only: row.get::<_, i32>(6)? != 0,
same_site: row.get(7)?,
creation_time: Self::chrome_time_to_unix(row.get(8)?),
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
name,
value,
domain,
path,
expires: Self::chrome_time_to_unix(expires_utc),
is_secure: is_secure != 0,
is_http_only: is_httponly != 0,
same_site: samesite,
creation_time: Self::chrome_time_to_unix(creation_utc),
last_accessed: Self::chrome_time_to_unix(last_access_utc),
})
})
.map_err(|e| format!("Failed to query cookies: {e}"))?
@@ -256,10 +395,12 @@ impl CookieManager {
Ok((copied, replaced))
}
/// Write cookies to a Chrome/Wayfern profile
/// Write cookies to a Chrome/Wayfern profile.
/// If an encryption key is available, stores cookies encrypted in encrypted_value.
fn write_chrome_cookies(
db_path: &Path,
cookies: &[UnifiedCookie],
encryption_key: Option<&[u8; 16]>,
) -> Result<(usize, usize), String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
@@ -272,6 +413,12 @@ impl CookieManager {
.as_secs() as i64;
for cookie in cookies {
// Prepare value/encrypted_value based on whether we have an encryption key
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
None => (cookie.value.as_str(), Vec::new()),
};
let existing: Option<i64> = conn
.query_row(
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
@@ -283,11 +430,12 @@ impl CookieManager {
if existing.is_some() {
conn
.execute(
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
"UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
WHERE host_key = ?9 AND name = ?10 AND path = ?11",
params![
&cookie.value,
value_str,
encrypted_bytes,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
cookie.is_http_only as i32,
@@ -308,12 +456,13 @@ impl CookieManager {
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
is_persistent, priority, samesite, source_scheme, source_port, source_type,
has_cross_site_ancestor, last_update_utc)
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)",
params![
Self::unix_to_chrome_time(cookie.creation_time),
&cookie.domain,
&cookie.name,
&cookie.value,
value_str,
encrypted_bytes,
&cookie.path,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
@@ -348,7 +497,10 @@ impl CookieManager {
let cookies = match profile.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&db_path)?,
"wayfern" => Self::read_chrome_cookies(&db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::read_chrome_cookies(&db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
@@ -401,7 +553,10 @@ impl CookieManager {
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
let all_cookies = match source.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(source, &profiles_dir);
Self::read_chrome_cookies(&source_db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
};
@@ -468,7 +623,10 @@ impl CookieManager {
let write_result = match target.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => {
let key = Self::get_chrome_encryption_key(target, &profiles_dir);
Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref())
}
_ => {
results.push(CookieCopyResult {
target_profile_id: target_id.clone(),
@@ -733,7 +891,10 @@ impl CookieManager {
let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::write_chrome_cookies(&db_path, &cookies, key.as_ref())
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
+109 -501
View File
@@ -56,7 +56,7 @@ impl Downloader {
}
#[cfg(test)]
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
pub fn new_for_test() -> Self {
Self {
client: Client::new(),
api_client: ApiClient::instance(),
@@ -67,87 +67,53 @@ impl Downloader {
}
}
#[cfg(test)]
pub async fn download_file(
&self,
download_url: &str,
dest_path: &Path,
filename: &str,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_path.join(filename);
let response = self
.client
.get(download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file_path)?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
}
Ok(file_path)
}
/// Resolve the actual download URL for browsers that need dynamic asset resolution
pub async fn resolve_download_url(
&self,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
_download_info: &DownloadInfo,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
.await?;
// Find the release with the matching version
let release = releases
.iter()
.find(|r| {
r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v'))
})
.ok_or(format!("Brave version {version} not found"))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists and handle different naming patterns
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
Ok(releases) => releases,
Err(e) => {
log::error!("Failed to fetch Zen releases: {e}");
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
}
};
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or_else(|| {
format!(
"Zen version {} not found. Available versions: {}",
version,
releases
.iter()
.take(5)
.map(|r| r.tag_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or_else(|| {
let available_assets: Vec<&str> =
release.assets.iter().map(|a| a.name.as_str()).collect();
format!(
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
version,
os,
arch,
available_assets.join(", ")
)
})?;
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
@@ -209,10 +175,6 @@ impl Downloader {
Ok(download_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
}
}
}
@@ -239,110 +201,6 @@ impl Downloader {
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
@@ -434,13 +292,6 @@ impl Downloader {
Ok(())
}
fn configure_camoufox_search_engine(
&self,
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
configure_camoufox_search_engine(browser_dir)
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
_app_handle: &tauri::AppHandle<R>,
@@ -457,16 +308,40 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check if this is a twilight release for special handling
let is_twilight =
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
// Determine if we have a partial file to resume
// Check existing file size — if it matches the expected size, skip download
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
existing_size = meta.len();
}
// Do a HEAD request to get the expected file size for skip/resume decisions
let head_response = self
.client
.head(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await
.ok();
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
// If existing file matches expected size, skip download entirely
if existing_size > 0 {
if let Some(expected) = expected_size {
if existing_size == expected {
log::info!(
"Archive {} already exists with correct size ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
// Satisfiable), delete the partial file and retry once without the Range header.
let response = {
@@ -555,11 +430,7 @@ impl Downloader {
0.0
};
let initial_stage = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let initial_stage = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -621,11 +492,7 @@ impl Downloader {
None
};
let stage_description = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let stage_description = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -844,11 +711,16 @@ impl Downloader {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
// Do not remove the archive or extracted files. Just drop the registry entry
// so it won't be reported as downloaded.
log::error!("Extraction failed for {browser_str} {version}: {e}");
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
log::info!("Deleting corrupt archive: {}", download_path.display());
let _ = std::fs::remove_file(&download_path);
}
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Remove browser-version pair from downloading set on error
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
@@ -857,6 +729,20 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
// Emit error stage so the UI shows a toast
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to extract browser: {e}").into());
}
}
@@ -1004,10 +890,6 @@ impl Downloader {
{
log::warn!("Failed to create version.json for Camoufox: {e}");
}
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
}
// Emit completion
@@ -1102,250 +984,24 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
}
}
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
/// On Linux: `<browser_dir>/camoufox/distribution/`
/// On Windows: `<browser_dir>/distribution/`
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
#[allow(clippy::vec_init_then_push)]
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "macos")]
{
if let Ok(entries) = std::fs::read_dir(browser_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
dirs.push(
entry
.path()
.join("Contents")
.join("Resources")
.join("distribution"),
);
}
}
}
}
#[cfg(target_os = "linux")]
{
dirs.push(browser_dir.join("camoufox").join("distribution"));
}
// Fallback for all platforms
dirs.push(browser_dir.join("distribution"));
dirs
}
/// Set DuckDuckGo as the default search engine in Camoufox.
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
/// Called both at download time and at launch time to cover existing installations.
pub fn configure_camoufox_search_engine(
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
// Find an existing policies.json, or pick the first candidate dir to create one
let (policies_path, mut policies) = {
let mut found = None;
for dir in &distribution_dirs {
let path = dir.join("policies.json");
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
found = Some((path, val));
break;
}
}
}
}
match found {
Some(f) => f,
None => {
// Pick the first candidate directory that exists (or can be created)
let target_dir = distribution_dirs
.iter()
.find(|d| d.parent().is_some_and(|p| p.exists()))
.or(distribution_dirs.first())
.ok_or("No suitable distribution directory found")?;
std::fs::create_dir_all(target_dir)?;
(
target_dir.join("policies.json"),
serde_json::json!({"policies": {}}),
)
}
}
};
// Check if already configured
let has_ddg_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
== Some("DuckDuckGo");
let has_ddg_engine = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Add"))
.and_then(|a| a.as_array())
.is_some_and(|arr| {
arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
});
if has_ddg_default && has_ddg_engine {
return Ok(());
}
let ddg_engine = serde_json::json!({
"Name": "DuckDuckGo",
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"Method": "GET",
"IconURL": "https://duckduckgo.com/favicon.ico",
"Alias": "ddg"
});
// Ensure policies.SearchEngines exists
let policies_obj = policies
.as_object_mut()
.ok_or("Invalid policies.json")?
.entry("policies")
.or_insert(serde_json::json!({}));
let se = policies_obj
.as_object_mut()
.ok_or("Invalid policies object")?
.entry("SearchEngines")
.or_insert(serde_json::json!({}));
if let Some(se_obj) = se.as_object_mut() {
// Set DuckDuckGo as default
se_obj.insert(
"Default".to_string(),
serde_json::Value::String("DuckDuckGo".to_string()),
);
// Add DuckDuckGo engine definition if not present
let add_arr = se_obj
.entry("Add")
.or_insert(serde_json::json!([]))
.as_array_mut()
.ok_or("SearchEngines.Add is not an array")?;
// Remove fake "None" engine
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
// Add DuckDuckGo if not already present
if !add_arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
{
add_arr.push(ddg_engine);
}
// Ensure DuckDuckGo is not in the Remove list
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
}
}
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!(
"Configured DuckDuckGo search engine in {}",
policies_path.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser_version_manager::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
MockServer::start().await
}
fn create_test_api_client(server: &MockServer) -> ApiClient {
let base_url = server.uri();
ApiClient::new_with_base_urls(
base_url.clone(), // firefox_api_base
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
)
}
#[tokio::test]
async fn test_resolve_firefox_download_url() {
let server = setup_mock_server().await;
async fn test_download_file_with_progress() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
filename: "firefox-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_chromium_download_url() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
filename: "chromium-test.zip".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_download_browser_with_progress() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
// Create a temporary directory for the test
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create test file content (simulating a small download)
let test_content = b"This is a test file content for download simulation";
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.respond_with(
@@ -1357,85 +1013,51 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/test-download", server.uri()),
filename: "test-file.dmg".to_string(),
is_archive: true,
};
// Create a mock app handle for testing
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/test-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "test-file.dmg")
.await;
assert!(result.is_ok());
let downloaded_file = result.unwrap();
assert!(downloaded_file.exists());
// Verify file content
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content, test_content);
}
#[tokio::test]
async fn test_download_browser_network_error() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_network_error() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/missing-file", server.uri()),
filename: "missing-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/missing-file", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "missing-file.dmg")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_download_browser_chunked_response() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_chunked_response() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create larger test content to simulate chunked transfer
let test_content = vec![42u8; 1024]; // 1KB of data
Mock::given(method("GET"))
@@ -1449,24 +1071,10 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/chunked-download", server.uri()),
filename: "chunked-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/chunked-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Chromium,
"1465660",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "chunked-file.dmg")
.await;
assert!(result.is_ok());
+1
View File
@@ -281,6 +281,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_ephemeral_dir_lifecycle() {
let profile_id = uuid::Uuid::new_v4();
let id_str = profile_id.to_string();
+4 -4
View File
@@ -829,8 +829,8 @@ impl ExtensionManager {
) -> Result<(), Box<dyn std::error::Error>> {
let group = self.get_group(group_id)?;
let browser_type = match browser {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Err(format!("Extensions are not supported for browser '{browser}'").into()),
};
@@ -871,8 +871,8 @@ impl ExtensionManager {
}
let browser_type = match profile.browser.as_str() {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Ok(Vec::new()),
};
+35 -103
View File
@@ -6,8 +6,8 @@ use crate::browser::BrowserType;
use crate::downloader::DownloadProgress;
use crate::events;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::process::Command;
#[cfg(target_os = "macos")]
use tokio::process::Command;
#[cfg(target_os = "macos")]
use std::fs::create_dir_all;
@@ -38,12 +38,7 @@ impl Extractor {
"camoufox"
} else if dest_dir.to_string_lossy().contains("wayfern") {
"wayfern"
} else if dest_dir.to_string_lossy().contains("firefox") {
"firefox"
} else if dest_dir.to_string_lossy().contains("zen") {
"zen"
} else {
// For other browsers, assume the structure is already correct
return Ok(());
};
@@ -237,17 +232,8 @@ impl Extractor {
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// First check file extension for DMG files since they're common on macOS
// and can have misleading magic numbers
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
if ext.to_lowercase() == "dmg" {
return Ok("dmg".to_string());
}
if ext.to_lowercase() == "msi" {
return Ok("msi".to_string());
}
}
// Always check magic bytes first — the file extension may be wrong
// (e.g. CDN serving a ZIP with .dmg extension)
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
file.read_exact(&mut buffer)?;
@@ -362,16 +348,20 @@ impl Extractor {
.args([
"attach",
"-nobrowse",
"-noverify",
"-noautoopen",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
.stdin(std::process::Stdio::null())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
@@ -387,12 +377,13 @@ impl Extractor {
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
log::info!("Failed to find .app in mount point: {e}");
log::error!("Failed to find .app in mount point: {e}");
// Try to unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err("No .app found after extraction".into());
@@ -412,16 +403,18 @@ impl Extractor {
app_entry.to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("Failed to copy app: {stderr}");
log::error!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err(format!("Failed to copy app: {stderr}").into());
@@ -432,18 +425,21 @@ impl Extractor {
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
.output()
.await;
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
.output()
.await;
log::info!("Removed quarantine attributes");
// Unmount the DMG
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -739,57 +735,19 @@ impl Extractor {
dest_dir: &Path,
browser_type: BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Zen => {
// Zen installer EXE needs to be run to install
#[cfg(target_os = "windows")]
{
self.install_zen_windows(exe_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
Err("Zen EXE installation is only supported on Windows".into())
}
}
_ => {
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
{
let _ = browser_type;
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
}
#[cfg(target_os = "windows")]
async fn install_zen_windows(
&self,
installer_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For Zen installer, we need to run it silently
let output = Command::new(installer_path)
.args(["/S", &format!("/D={}", dest_dir.display())])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
}
fn flatten_single_directory_archive(
&self,
dest_dir: &Path,
@@ -954,8 +912,6 @@ impl Extractor {
"firefox.exe",
"chrome.exe",
"chromium.exe",
"zen.exe",
"brave.exe",
"camoufox.exe",
"wayfern.exe",
];
@@ -1023,8 +979,6 @@ impl Extractor {
if file_name.contains("firefox")
|| file_name.contains("chrome")
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("browser")
|| file_name.contains("camoufox")
|| file_name.contains("wayfern")
@@ -1075,31 +1029,14 @@ impl Extractor {
// Enhanced list of common browser executable names
let exe_names = [
// Firefox variants
// Firefox variants (used by Camoufox)
"firefox",
"firefox-bin",
"firefox-esr",
"firefox-trunk",
// Chrome/Chromium variants
// Chrome/Chromium variants (used by Wayfern)
"chrome",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium-bin",
// Zen Browser
"zen",
"zen-browser",
"zen-bin",
// Brave variants
"brave",
"brave-browser",
"brave-browser-stable",
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Camoufox variants
"camoufox",
"camoufox-bin",
@@ -1130,17 +1067,12 @@ impl Extractor {
"firefox",
"chrome",
"chromium",
"brave",
"zen",
"camoufox",
"wayfern",
".",
"./",
"firefox",
"Browser",
"browser",
"opt/google/chrome",
"opt/brave.com/brave",
"opt/camoufox",
"usr/lib/firefox",
"usr/lib/chromium",
+12 -1
View File
@@ -174,6 +174,13 @@ impl GeoIPDownloader {
let mmdb_path = Self::get_mmdb_file_path()?;
// Always download to a temp file first, then atomically rename.
// This prevents corruption if the app is closed mid-download.
let temp_path = mmdb_path.with_extension("mmdb.downloading");
// Remove any leftover temp file from a previous interrupted download
let _ = fs::remove_file(&temp_path).await;
// Download the file
let response = self.client.get(&download_url).send().await?;
@@ -189,7 +196,7 @@ impl GeoIPDownloader {
let total_size = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut file = fs::File::create(&mmdb_path).await?;
let mut file = fs::File::create(&temp_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -237,6 +244,10 @@ impl GeoIPDownloader {
}
file.flush().await?;
drop(file);
// Atomically replace the old database with the new one
fs::rename(&temp_path, &mmdb_path).await?;
// Write download timestamp
let timestamp_path = Self::get_timestamp_path();
+492
View File
@@ -0,0 +1,492 @@
use rand::{Rng, RngExt};
use std::collections::{HashMap, HashSet};
const PROB_ERROR: f64 = 0.04;
const PROB_SWAP_ERROR: f64 = 0.015;
const PROB_NOTICE_ERROR: f64 = 0.85;
const SPEED_BOOST_COMMON_WORD: f64 = 0.6;
const SPEED_PENALTY_COMPLEX_WORD: f64 = 1.3;
const SPEED_BOOST_CLOSE_KEYS: f64 = 0.5;
const SPEED_BOOST_BIGRAM: f64 = 0.4;
const TIME_KEYSTROKE_STD: f64 = 0.03;
const TIME_BACKSPACE_MEAN: f64 = 0.12;
const TIME_BACKSPACE_STD: f64 = 0.02;
const TIME_REACTION_MEAN: f64 = 0.35;
const TIME_REACTION_STD: f64 = 0.1;
const TIME_UPPERCASE_PENALTY: f64 = 0.2;
const TIME_SPACE_PAUSE_MEAN: f64 = 0.25;
const TIME_SPACE_PAUSE_STD: f64 = 0.05;
const FATIGUE_FACTOR: f64 = 1.0005;
const AVG_WORD_LENGTH: f64 = 5.0;
const WPM_STD: f64 = 10.0;
const DEFAULT_WPM: f64 = 80.0;
#[derive(Debug, Clone)]
pub enum TypingAction {
Char(char),
Backspace,
}
#[derive(Debug, Clone)]
pub struct TypingEvent {
pub time: f64,
pub action: TypingAction,
}
struct KeyboardLayout {
pos_map: HashMap<char, (usize, usize)>,
grid: Vec<Vec<char>>,
}
impl KeyboardLayout {
fn new() -> Self {
let grid: Vec<Vec<char>> = vec![
"`1234567890-=".chars().collect(),
"qwertyuiop[]\\".chars().collect(),
"asdfghjkl;'".chars().collect(),
"zxcvbnm,./".chars().collect(),
];
let mut pos_map = HashMap::new();
for (r, row) in grid.iter().enumerate() {
for (c, &ch) in row.iter().enumerate() {
pos_map.insert(ch, (r, c));
}
}
KeyboardLayout { pos_map, grid }
}
fn has_key(&self, ch: char) -> bool {
self.pos_map.contains_key(&ch.to_ascii_lowercase())
}
fn get_neighbor_keys(&self, ch: char) -> Vec<char> {
let ch = ch.to_ascii_lowercase();
let (r, c) = match self.pos_map.get(&ch) {
Some(&pos) => pos,
None => return vec![],
};
let deltas: [(i32, i32); 8] = [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
];
let mut neighbors = Vec::new();
for (dr, dc) in &deltas {
let nr = r as i32 + dr;
let nc = c as i32 + dc;
if nr >= 0 && (nr as usize) < self.grid.len() {
let row = &self.grid[nr as usize];
if nc >= 0 && (nc as usize) < row.len() {
neighbors.push(row[nc as usize]);
}
}
}
neighbors
}
fn get_distance(&self, c1: char, c2: char) -> f64 {
let c1 = c1.to_ascii_lowercase();
let c2 = c2.to_ascii_lowercase();
match (self.pos_map.get(&c1), self.pos_map.get(&c2)) {
(Some(&(r1, c1p)), Some(&(r2, c2p))) => {
let dr = r1 as f64 - r2 as f64;
let dc = c1p as f64 - c2p as f64;
(dr * dr + dc * dc).sqrt()
}
_ => 4.0,
}
}
fn get_random_neighbor(&self, ch: char, rng: &mut impl Rng) -> char {
let neighbors = self.get_neighbor_keys(ch);
if neighbors.is_empty() {
let flat: Vec<char> = self.grid.iter().flat_map(|r| r.iter().copied()).collect();
flat[rng.random_range(0..flat.len())]
} else {
neighbors[rng.random_range(0..neighbors.len())]
}
}
}
fn normal_sample(rng: &mut impl Rng, mean: f64, std_dev: f64) -> f64 {
// Box-Muller transform
let u1: f64 = rng.random::<f64>().max(1e-10);
let u2: f64 = rng.random::<f64>();
let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos();
mean + std_dev * z
}
static COMMON_WORDS: &[&str] = &[
"the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with",
"he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her",
"she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up",
"out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time",
"no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could",
"them", "see", "other", "than", "then", "now", "look", "only", "come", "its", "over", "think",
"also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even",
"new", "want", "because",
];
static COMMON_BIGRAMS: &[&str] = &[
"th", "he", "in", "er", "an", "re", "on", "at", "en", "nd", "ti", "es", "or", "te", "of", "ed",
"is", "it", "al", "ar", "st", "to", "nt", "ng", "se", "ha", "as", "ou", "io", "le", "ve", "co",
"me", "de", "hi", "ri", "ro", "ic", "ne", "ea", "ra", "ce",
];
fn get_word_difficulty(word: &str) -> &'static str {
let lower = word.to_lowercase();
let trimmed = lower.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ':'));
let common_set: HashSet<&str> = COMMON_WORDS.iter().copied().collect();
if common_set.contains(trimmed) {
return "common";
}
let is_long = trimmed.len() > 8;
let has_complex = trimmed.chars().any(|c| matches!(c, 'z' | 'x' | 'q' | 'j'));
if is_long || has_complex {
return "complex";
}
"normal"
}
fn is_common_bigram(c1: char, c2: char) -> bool {
let bigram = format!("{}{}", c1.to_ascii_lowercase(), c2.to_ascii_lowercase());
let bigram_set: HashSet<&str> = COMMON_BIGRAMS.iter().copied().collect();
bigram_set.contains(bigram.as_str())
}
pub struct MarkovTyper {
target: Vec<char>,
current: Vec<char>,
keyboard: KeyboardLayout,
base_keystroke_time: f64,
fatigue_multiplier: f64,
mental_cursor_pos: usize,
last_char_typed: Option<char>,
total_time: f64,
last_was_backspace: bool,
rng: rand::rngs::ThreadRng,
}
impl MarkovTyper {
pub fn new(text: &str, wpm: Option<f64>) -> Self {
let mut rng = rand::rng();
let target_wpm = wpm.unwrap_or(DEFAULT_WPM);
let session_wpm = normal_sample(&mut rng, target_wpm, WPM_STD).max(10.0);
let base_keystroke_time = 60.0 / (session_wpm * AVG_WORD_LENGTH);
MarkovTyper {
target: text.chars().collect(),
current: Vec::new(),
keyboard: KeyboardLayout::new(),
base_keystroke_time,
fatigue_multiplier: 1.0,
mental_cursor_pos: 0,
last_char_typed: None,
total_time: 0.0,
last_was_backspace: false,
rng,
}
}
fn get_current_word(&self) -> Option<String> {
if self.mental_cursor_pos >= self.target.len() {
return None;
}
let mut start = self.mental_cursor_pos;
while start > 0 && self.target[start - 1] != ' ' {
start -= 1;
}
let mut end = self.mental_cursor_pos;
while end < self.target.len() && self.target[end] != ' ' {
end += 1;
}
Some(self.target[start..end].iter().collect())
}
fn calculate_keystroke_time(&mut self, ch: char) -> f64 {
let mut time = self.base_keystroke_time * self.fatigue_multiplier;
if let Some(word) = self.get_current_word() {
match get_word_difficulty(&word) {
"common" => time *= SPEED_BOOST_COMMON_WORD,
"complex" => time *= SPEED_PENALTY_COMPLEX_WORD,
_ => {}
}
}
if let Some(last) = self.last_char_typed {
if is_common_bigram(last, ch) {
time *= SPEED_BOOST_BIGRAM;
} else {
let dist = self.keyboard.get_distance(last, ch);
if dist > 0.0 && dist < 2.0 {
time *= SPEED_BOOST_CLOSE_KEYS;
} else if dist > 4.0 {
time *= 1.2;
}
}
}
if ch == ' ' {
time += normal_sample(&mut self.rng, TIME_SPACE_PAUSE_MEAN, TIME_SPACE_PAUSE_STD);
} else if ch.is_uppercase() {
time += TIME_UPPERCASE_PENALTY;
}
let dt = normal_sample(&mut self.rng, time, TIME_KEYSTROKE_STD);
dt.max(0.02)
}
fn step(&mut self) -> Option<TypingEvent> {
if self.current == self.target {
return None;
}
// Find first error position
let mut first_error_pos = self.target.len();
let min_len = self.current.len().min(self.target.len());
for i in 0..min_len {
if self.current[i] != self.target[i] {
first_error_pos = i;
break;
}
}
if self.current.len() > self.target.len() && first_error_pos == self.target.len() {
first_error_pos = self.target.len();
}
// Error correction
if first_error_pos < self.current.len() {
let mut should_correct = false;
if self.last_was_backspace || self.mental_cursor_pos >= self.target.len() {
should_correct = true;
} else if !self.current.is_empty() {
let last_char = *self.current.last().unwrap();
let distance = self.current.len() - first_error_pos;
if " \n\t.,;!?:()[]{}\"'<>".contains(last_char) {
should_correct = true;
} else if distance >= 2 {
if self.rng.random::<f64>() < 0.8 {
should_correct = true;
}
} else if distance == 1 && self.rng.random::<f64>() < PROB_NOTICE_ERROR {
should_correct = true;
}
}
if should_correct {
if !self.last_was_backspace {
let dt = normal_sample(&mut self.rng, TIME_REACTION_MEAN, TIME_REACTION_STD).max(0.1);
self.total_time += dt;
}
let dt = normal_sample(&mut self.rng, TIME_BACKSPACE_MEAN, TIME_BACKSPACE_STD);
self.total_time += dt;
self.current.pop();
self.mental_cursor_pos = self.current.len();
self.last_was_backspace = true;
return Some(TypingEvent {
time: self.total_time,
action: TypingAction::Backspace,
});
}
}
self.last_was_backspace = false;
if self.mental_cursor_pos > self.current.len() {
self.mental_cursor_pos = self.current.len();
}
if self.mental_cursor_pos >= self.target.len() {
return None;
}
let char_intended = self.target[self.mental_cursor_pos];
self.fatigue_multiplier *= FATIGUE_FACTOR;
// Non-QWERTY characters (CJK, Cyrillic, etc.) are composed via IME —
// skip error simulation entirely, just apply realistic timing.
let on_keyboard = self.keyboard.has_key(char_intended);
// Swap error (only for characters on the physical keyboard)
if on_keyboard && self.mental_cursor_pos + 1 < self.target.len() {
let char_after = self.target[self.mental_cursor_pos + 1];
if char_after != ' '
&& char_after != char_intended
&& self.keyboard.has_key(char_after)
&& self.rng.random::<f64>() < PROB_SWAP_ERROR
{
let dt = self.calculate_keystroke_time(char_after);
self.total_time += dt;
self.current.push(char_after);
self.last_char_typed = Some(char_after);
self.mental_cursor_pos += 1;
return Some(TypingEvent {
time: self.total_time,
action: TypingAction::Char(char_after),
});
}
}
// Normal typing with possible error (errors only for QWERTY characters)
let typed_char = if on_keyboard {
let mut current_prob_error = PROB_ERROR;
if let Some(word) = self.get_current_word() {
match get_word_difficulty(&word) {
"complex" => current_prob_error *= 1.5,
"common" => current_prob_error *= 0.5,
_ => {}
}
}
if self.rng.random::<f64>() < current_prob_error {
self
.keyboard
.get_random_neighbor(char_intended, &mut self.rng)
} else {
char_intended
}
} else {
char_intended
};
let dt = self.calculate_keystroke_time(typed_char);
self.total_time += dt;
self.current.push(typed_char);
self.last_char_typed = Some(typed_char);
self.mental_cursor_pos += 1;
Some(TypingEvent {
time: self.total_time,
action: TypingAction::Char(typed_char),
})
}
pub fn run(mut self) -> Vec<TypingEvent> {
let max_steps = self.target.len() * 10;
let mut events = Vec::new();
let mut steps = 0;
while let Some(event) = self.step() {
events.push(event);
steps += 1;
if steps > max_steps {
break;
}
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generates_events() {
let typer = MarkovTyper::new("hello", Some(60.0));
let events = typer.run();
assert!(!events.is_empty());
// Final text should be "hello" — verify by replaying
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, "hello");
}
#[test]
fn test_timing_increases() {
let typer = MarkovTyper::new("test", Some(60.0));
let events = typer.run();
for window in events.windows(2) {
assert!(window[1].time >= window[0].time);
}
}
#[test]
fn test_empty_text() {
let typer = MarkovTyper::new("", Some(60.0));
let events = typer.run();
assert!(events.is_empty());
}
#[test]
fn test_chinese_text() {
let input = "你好世界";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_russian_text() {
let input = "Привет мир";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_japanese_text() {
let input = "東京タワー";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_mixed_latin_and_cjk() {
let input = "Hello 你好 world";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
}
+84 -43
View File
@@ -26,6 +26,7 @@ mod extension_manager;
mod extraction;
mod geoip_downloader;
mod group_manager;
mod human_typing;
mod ip_utils;
mod platform_browser;
mod profile;
@@ -36,6 +37,7 @@ pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod sync;
mod synchronizer;
pub mod traffic_stats;
mod wayfern_manager;
mod wayfern_terms;
@@ -207,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
async fn create_stored_proxy(
app_handle: tauri::AppHandle,
name: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
crate::proxy_manager::PROXY_MANAGER
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
} else if let Some(settings) = proxy_settings {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
} else {
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
}
}
#[tauri::command]
@@ -225,10 +237,26 @@ async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
// Check if this is a dynamic proxy update
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
if is_dynamic || dynamic_proxy_url.is_some() {
crate::proxy_manager::PROXY_MANAGER
.update_dynamic_proxy(
&app_handle,
&proxy_id,
name,
dynamic_proxy_url,
dynamic_proxy_format,
)
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
} else {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
}
#[tauri::command]
@@ -241,13 +269,43 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
#[tauri::command]
async fn check_proxy_validity(
proxy_id: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
// For dynamic proxies, fetch settings first
let settings = if let Some(s) = proxy_settings {
s
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
crate::proxy_manager::PROXY_MANAGER
.resolve_dynamic_proxy(&proxy_id)
.await?
} else {
crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(&proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?
};
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(&proxy_id, &proxy_settings)
.check_proxy_validity(&proxy_id, &settings)
.await
}
#[tauri::command]
async fn fetch_dynamic_proxy(
url: String,
format: String,
) -> Result<crate::browser::ProxySettings, String> {
let settings = crate::proxy_manager::PROXY_MANAGER
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by routing through a temporary local proxy
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity("_dynamic_test", &settings)
.await
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
Ok(settings)
}
#[tauri::command]
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
@@ -1068,41 +1126,18 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// TODO(v0.17+): Remove this migration block after a few releases.
// Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir.
// Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent
// StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/.
// Auto-start MCP server if it was previously enabled
{
let old_dir = crate::app_dirs::proxies_dir();
let new_dir = crate::app_dirs::proxy_workers_dir();
if old_dir.exists() {
if let Err(e) = std::fs::create_dir_all(&new_dir) {
log::error!("Failed to create proxy_workers dir: {e}");
} else if let Ok(entries) = std::fs::read_dir(&old_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if (name.starts_with("proxy_") || name.starts_with("vpnw_"))
&& name.ends_with(".json")
{
let dest = new_dir.join(name);
match std::fs::rename(&path, &dest) {
Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"),
Err(e) => {
// rename fails across filesystems, fall back to copy+delete
if let Ok(content) = std::fs::read(&path) {
if std::fs::write(&dest, &content).is_ok() {
let _ = std::fs::remove_file(&path);
log::info!("Migrated worker config {name} to proxy_workers/ (copy)");
}
} else {
log::warn!("Failed to migrate worker config {name}: {e}");
}
}
}
}
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
}
});
}
}
}
@@ -1499,7 +1534,7 @@ pub fn run() {
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
@@ -1594,6 +1629,7 @@ pub fn run() {
update_stored_proxy,
delete_stored_proxy,
check_proxy_validity,
fetch_dynamic_proxy,
get_cached_proxy_check,
export_proxies,
import_proxies_json,
@@ -1691,6 +1727,11 @@ pub fn run() {
// Team lock commands
team_lock::get_team_locks,
team_lock::get_team_lock_status,
// Synchronizer commands
synchronizer::start_sync_session,
synchronizer::stop_sync_session,
synchronizer::remove_sync_follower,
synchronizer::get_sync_sessions,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
File diff suppressed because it is too large Load Diff
+3
View File
@@ -5,6 +5,7 @@ use std::process::Command;
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
pub mod macos {
use super::*;
use sysinfo::{Pid, System};
@@ -468,6 +469,7 @@ end try
}
#[cfg(target_os = "windows")]
#[allow(dead_code)]
pub mod windows {
use super::*;
@@ -680,6 +682,7 @@ pub mod windows {
}
#[cfg(target_os = "linux")]
#[allow(dead_code)]
pub mod linux {
use super::*;
+8 -23
View File
@@ -1242,10 +1242,7 @@ impl ProfileManager {
let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
if profile.browser == "camoufox" {
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1253,7 +1250,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
@@ -1262,7 +1259,6 @@ impl ProfileManager {
if profile_path_match {
is_running = true;
found_pid = Some(pid);
// Found existing browser process
}
}
}
@@ -1275,16 +1271,12 @@ impl ProfileManager {
// Check if this is the right browser executable first
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name.contains("firefox")
&& !exe_name.contains("developer")
&& !exe_name.contains("camoufox")
"camoufox" => exe_name.contains("camoufox") || exe_name.contains("firefox"),
"wayfern" => {
exe_name.contains("wayfern")
|| exe_name.contains("chromium")
|| exe_name.contains("chrome")
}
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium"),
"brave" => exe_name.contains("brave"),
// Camoufox is handled via CamoufoxManager, not PID-based checking
_ => false,
};
@@ -1300,13 +1292,6 @@ impl ProfileManager {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "camoufox" {
// Camoufox uses user_data_dir like Chromium browsers
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
} else if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1314,7 +1299,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
+295 -91
View File
@@ -4,22 +4,38 @@ use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
pub browser: String,
pub mapped_browser: String,
pub name: String,
pub path: String,
pub description: String,
}
fn map_browser_type(browser: &str) -> &str {
match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
_ => "wayfern",
}
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
impl ProfileImporter {
@@ -28,6 +44,8 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
}
@@ -35,31 +53,18 @@ impl ProfileImporter {
&PROFILE_IMPORTER
}
/// Detect existing browser profiles on the system
pub fn detect_existing_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
// Detect Firefox profiles
detected_profiles.extend(self.detect_firefox_profiles()?);
// Detect Chrome profiles
detected_profiles.extend(self.detect_chrome_profiles()?);
// Detect Brave profiles
detected_profiles.extend(self.detect_brave_profiles()?);
// Detect Firefox Developer Edition profiles
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
// Detect Chromium profiles
detected_profiles.extend(self.detect_chromium_profiles()?);
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
.into_iter()
@@ -69,7 +74,6 @@ impl ProfileImporter {
Ok(unique_profiles)
}
/// Detect Firefox profiles
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -84,12 +88,10 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
// Primary location in AppData\Roaming
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
// Also check AppData\Local for portable installations
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
@@ -106,7 +108,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Firefox Developer Edition profiles
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -114,13 +115,11 @@ impl ProfileImporter {
#[cfg(target_os = "macos")]
{
// Firefox Developer Edition on macOS uses separate profile directories
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
@@ -129,7 +128,6 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
// Firefox Developer Edition on Windows typically uses separate directories
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
@@ -138,7 +136,6 @@ impl ProfileImporter {
#[cfg(target_os = "linux")]
{
// Firefox Developer Edition on Linux uses separate directories
let firefox_dev_dir = self
.base_dirs
.home_dir()
@@ -151,7 +148,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chrome profiles
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -180,7 +176,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chromium profiles
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -209,7 +204,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Brave profiles
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -241,7 +235,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Zen Browser profiles
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -272,7 +265,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Firefox-style profiles directory
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
@@ -284,7 +276,6 @@ impl ProfileImporter {
return Ok(profiles);
}
// Read profiles.ini file if it exists
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
@@ -295,7 +286,6 @@ impl ProfileImporter {
}
}
// Also scan directory for any profile folders not in profiles.ini
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -307,11 +297,11 @@ impl ProfileImporter {
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
// Check if this profile was already found in profiles.ini
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
@@ -329,7 +319,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Parse Firefox profiles.ini file
fn parse_firefox_profiles_ini(
&self,
content: &str,
@@ -346,7 +335,6 @@ impl ProfileImporter {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
// Save previous profile if complete
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -370,6 +358,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -377,7 +366,6 @@ impl ProfileImporter {
}
}
// Start new section
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
@@ -398,7 +386,6 @@ impl ProfileImporter {
}
}
// Handle last profile
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -422,6 +409,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -432,7 +420,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Chrome-style profiles directory
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -444,11 +431,11 @@ impl ProfileImporter {
return Ok(profiles);
}
// Check for Default profile
let default_profile = browser_dir.join("Default");
if default_profile.exists() && default_profile.join("Preferences").exists() {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(browser_type)
@@ -458,7 +445,6 @@ impl ProfileImporter {
});
}
// Check for Profile X directories
if let Ok(entries) = fs::read_dir(browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -466,9 +452,10 @@ impl ProfileImporter {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
let profile_number = &dir_name[8..];
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Profile {}",
self.get_browser_display_name(browser_type),
@@ -485,7 +472,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Get browser display name
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"firefox" => "Firefox",
@@ -493,28 +479,36 @@ impl ProfileImporter {
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"zen" => "Zen Browser",
"camoufox" => "Camoufox",
"wayfern" => "Wayfern",
_ => "Unknown Browser",
}
}
/// Import a profile from an existing browser profile
pub fn import_profile(
#[allow(clippy::too_many_arguments)]
pub async fn import_profile(
&self,
app_handle: &tauri::AppHandle,
source_path: &str,
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err("Source profile path does not exist".into());
}
// Validate browser type
let _browser_type = BrowserType::from_str(browser_type)
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
let mapped = map_browser_type(browser_type);
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
crate::cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
}
}
// Check if a profile with this name already exists
let existing_profiles = self.profile_manager.list_profiles()?;
if existing_profiles
.iter()
@@ -523,7 +517,6 @@ impl ProfileImporter {
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// Generate UUID for new profile and create the directory structure
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = self.profile_manager.get_profiles_dir();
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
@@ -532,32 +525,227 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Copy all files from source to destination profile subdirectory
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
// Create the profile metadata without overwriting the imported data
// We need to find a suitable version for this browser type
let available_versions = self.get_default_version_for_browser(browser_type)?;
let version = self.get_default_version_for_browser(mapped)?;
let profile = crate::profile::BrowserProfile {
let final_camoufox_config = if mapped == "camoufox" {
let mut config = camoufox_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Camoufox.app")
.join("Contents")
.join("MacOS")
.join("camoufox");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("camoufox.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("camoufox");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("chrome.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("chrome");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.wayfern_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let profile = BrowserProfile {
id: profile_id,
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
proxy_id: None,
browser: mapped.to_string(),
version,
proxy_id,
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
camoufox_config: final_camoufox_config,
wayfern_config: final_wayfern_config,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(crate::profile::types::get_host_os()),
host_os: Some(get_host_os()),
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
@@ -565,7 +753,6 @@ impl ProfileImporter {
created_by_email: None,
};
// Save the profile metadata
self.profile_manager.save_profile(&profile)?;
log::info!(
@@ -577,12 +764,10 @@ impl ProfileImporter {
Ok(())
}
/// Get a default version for a browser type
fn get_default_version_for_browser(
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Check if any version of the browser is downloaded
let downloaded_versions = self
.downloaded_browsers_registry
.get_downloaded_versions(browser_type);
@@ -591,15 +776,16 @@ impl ProfileImporter {
return Ok(version.clone());
}
// If no downloaded versions found, return an error
Err(format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
).into())
Err(
format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
)
.into(),
)
}
/// Recursively copy directory contents
pub fn copy_directory_recursive(
source: &Path,
destination: &Path,
@@ -624,7 +810,6 @@ impl ProfileImporter {
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::instance();
@@ -635,17 +820,41 @@ pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String>
#[tauri::command]
pub async fn import_browser_profile(
app_handle: tauri::AppHandle,
source_path: String,
browser_type: String,
new_profile_name: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
.or_else(|| wayfern_config.as_ref().and_then(|c| c.os.as_deref()));
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let importer = ProfileImporter::instance();
importer
.import_profile(&source_path, &browser_type, &new_profile_name)
.import_profile(
&app_handle,
&source_path,
&browser_type,
&new_profile_name,
proxy_id,
camoufox_config,
wayfern_config,
)
.await
.map_err(|e| format!("Failed to import profile: {e}"))
}
// Global singleton instance
lazy_static::lazy_static! {
static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
}
@@ -658,10 +867,7 @@ mod tests {
fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Set up a temporary home directory for testing
env::set_var("HOME", temp_dir.path());
let importer = ProfileImporter::new();
(importer, temp_dir)
}
@@ -669,7 +875,6 @@ mod tests {
#[test]
fn test_profile_importer_creation() {
let (_importer, _temp_dir) = create_test_profile_importer();
// Test passes if no panic occurs
}
#[test]
@@ -693,19 +898,25 @@ mod tests {
);
}
#[test]
fn test_map_browser_type() {
assert_eq!(map_browser_type("firefox"), "camoufox");
assert_eq!(map_browser_type("firefox-developer"), "camoufox");
assert_eq!(map_browser_type("zen"), "camoufox");
assert_eq!(map_browser_type("chromium"), "wayfern");
assert_eq!(map_browser_type("brave"), "wayfern");
assert_eq!(map_browser_type("camoufox"), "camoufox");
assert_eq!(map_browser_type("wayfern"), "wayfern");
assert_eq!(map_browser_type("something_else"), "wayfern");
}
#[test]
fn test_detect_existing_profiles_no_panic() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should not panic even if no browser profiles exist
let result = importer.detect_existing_profiles();
assert!(result.is_ok(), "detect_existing_profiles should not fail");
let _profiles = result.unwrap();
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec (length check is always true for Vec, but shows intent)
}
#[test]
@@ -764,12 +975,10 @@ mod tests {
fn test_parse_firefox_profiles_ini_valid() {
let (importer, temp_dir) = create_test_profile_importer();
// Create a mock profile directory
let profiles_dir = temp_dir.path().join("profiles");
let profile_dir = profiles_dir.join("test.profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
// Create a prefs.js file to make it look like a valid profile
let prefs_file = profile_dir.join("prefs.js");
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
@@ -788,31 +997,27 @@ Path=test.profile
assert_eq!(profiles.len(), 1, "Should find one profile");
assert_eq!(profiles[0].name, "Firefox - Test Profile");
assert_eq!(profiles[0].browser, "firefox");
assert_eq!(profiles[0].mapped_browser, "camoufox");
}
#[test]
fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Create source directory structure
let source_dir = temp_dir.path().join("source");
let source_subdir = source_dir.join("subdir");
fs::create_dir_all(&source_subdir).expect("Should create source directories");
// Create some test files
let source_file1 = source_dir.join("file1.txt");
let source_file2 = source_subdir.join("file2.txt");
fs::write(&source_file1, "content1").expect("Should create file1");
fs::write(&source_file2, "content2").expect("Should create file2");
// Create destination directory
let dest_dir = temp_dir.path().join("dest");
// Copy recursively
let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
assert!(result.is_ok(), "Should copy directory successfully");
// Verify files were copied
let dest_file1 = dest_dir.join("file1.txt");
let dest_file2 = dest_dir.join("subdir").join("file2.txt");
@@ -830,8 +1035,7 @@ Path=test.profile
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should fail since no versions are downloaded in test environment
let result = importer.get_default_version_for_browser("firefox");
let result = importer.get_default_version_for_browser("camoufox");
assert!(
result.is_err(),
"Should fail when no versions are available"
+508 -5
View File
@@ -117,6 +117,10 @@ pub struct StoredProxy {
pub geo_city: Option<String>,
#[serde(default)]
pub geo_isp: Option<String>,
#[serde(default)]
pub dynamic_proxy_url: Option<String>,
#[serde(default)]
pub dynamic_proxy_format: Option<String>,
}
impl StoredProxy {
@@ -135,9 +139,15 @@ impl StoredProxy {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
}
}
pub fn is_dynamic(&self) -> bool {
self.dynamic_proxy_url.is_some()
}
/// Migrate legacy geo_state to geo_region
pub fn migrate_geo_fields(&mut self) {
if self.geo_region.is_none() && self.geo_state.is_some() {
@@ -450,6 +460,8 @@ impl ProxyManager {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
drop(stored_proxies);
@@ -639,6 +651,8 @@ impl ProxyManager {
geo_region: region,
geo_city: city,
geo_isp: isp,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
{
@@ -914,19 +928,33 @@ impl ProxyManager {
url
}
// Check if a proxy is valid by making HTTP requests through it
// Check if a proxy is valid by routing through a temporary local donut-proxy.
// This tests the exact same code path the browser uses, ensuring that if the
// check passes, the browser connection will work too.
pub async fn check_proxy_validity(
&self,
proxy_id: &str,
proxy_settings: &ProxySettings,
) -> Result<ProxyCheckResult, String> {
let proxy_url = Self::build_proxy_url(proxy_settings);
let upstream_url = Self::build_proxy_url(proxy_settings);
// Fetch public IP through the proxy using shared IP utilities
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
// Start a temporary local proxy that tunnels through the upstream
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
.await
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let proxy_id_clone = proxy_config.id.clone();
// Fetch public IP through the local proxy (same path the browser uses)
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
// Stop the temporary proxy regardless of result
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
let ip = match ip_result {
Ok(ip) => ip,
Err(e) => {
// Save failed check result
let failed_result = ProxyCheckResult {
ip: String::new(),
city: None,
@@ -965,6 +993,270 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
// Check if a stored proxy is dynamic
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
}
// Fetch proxy settings from a dynamic proxy URL
pub async fn fetch_dynamic_proxy(
&self,
url: &str,
format: &str,
) -> Result<ProxySettings, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Dynamic proxy URL returned status {}",
response.status()
));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Dynamic proxy URL returned empty response".to_string());
}
match format {
"json" => Self::parse_dynamic_proxy_json(body),
"text" => Self::parse_dynamic_proxy_text(body),
_ => Err(format!("Unsupported dynamic proxy format: {format}")),
}
}
// Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
let obj = json
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?
.to_string();
let port = obj
.get("port")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
as u16;
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.unwrap_or("http")
.to_lowercase();
let username = obj
.get("username")
.or_else(|| obj.get("user"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let password = obj
.get("password")
.or_else(|| obj.get("pass"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
}
// Parse text format using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
return Err("Empty text response".to_string());
}
match Self::parse_single_proxy_line(line) {
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
}),
ProxyParseResult::Ambiguous {
possible_formats, ..
} => Err(format!(
"Ambiguous proxy format. Could be: {}",
possible_formats.join(" or ")
)),
ProxyParseResult::Invalid { reason, .. } => {
Err(format!("Failed to parse proxy response: {reason}"))
}
}
}
// Resolve dynamic proxy: fetch from URL and return settings
pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result<ProxySettings, String> {
let (url, format) = {
let stored_proxies = self.stored_proxies.lock().unwrap();
let proxy = stored_proxies
.get(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) {
(Some(url), Some(format)) => (url.clone(), format.clone()),
_ => return Err("Proxy is not a dynamic proxy".to_string()),
}
};
self.fetch_dynamic_proxy(&url, &format).await
}
// Create a dynamic stored proxy
pub fn create_dynamic_proxy(
&self,
_app_handle: &tauri::AppHandle,
name: String,
url: String,
format: String,
) -> Result<StoredProxy, String> {
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.values().any(|p| p.name == name) {
return Err(format!("Proxy with name '{name}' already exists"));
}
}
let placeholder_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "dynamic".to_string(),
port: 0,
username: None,
password: None,
};
let mut stored_proxy = StoredProxy::new(name, placeholder_settings);
stored_proxy.dynamic_proxy_url = Some(url);
stored_proxy.dynamic_proxy_format = Some(format);
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
}
if let Err(e) = self.save_proxy(&stored_proxy) {
log::warn!("Failed to save proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
if stored_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = stored_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(stored_proxy)
}
// Update a dynamic proxy's URL and format
pub fn update_dynamic_proxy(
&self,
_app_handle: &tauri::AppHandle,
proxy_id: &str,
name: Option<String>,
url: Option<String>,
format: Option<String>,
) -> Result<StoredProxy, String> {
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if !stored_proxies.contains_key(proxy_id) {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
if let Some(ref new_name) = name {
if stored_proxies
.values()
.any(|p| p.id != proxy_id && p.name == *new_name)
{
return Err(format!("Proxy with name '{new_name}' already exists"));
}
}
}
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap();
if let Some(new_name) = name {
stored_proxy.update_name(new_name);
}
if let Some(new_url) = url {
stored_proxy.dynamic_proxy_url = Some(new_url);
}
if let Some(new_format) = format {
stored_proxy.dynamic_proxy_format = Some(new_format);
}
stored_proxy.clone()
};
if let Err(e) = self.save_proxy(&updated_proxy) {
log::warn!("Failed to save proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
if updated_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(updated_proxy)
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2835,6 +3127,8 @@ mod tests {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
// Before migration
@@ -3112,4 +3406,213 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_stored_proxy_is_dynamic() {
let mut proxy = StoredProxy::new(
"test".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "h.com".to_string(),
port: 80,
username: None,
password: None,
},
);
assert!(!proxy.is_dynamic());
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
assert!(proxy.is_dynamic());
}
#[test]
fn test_is_dynamic_proxy_via_manager() {
let pm = ProxyManager::new();
let mut proxy = StoredProxy::new(
"DynTest".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "dynamic".to_string(),
port: 0,
username: None,
password: None,
},
);
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
proxy.dynamic_proxy_format = Some("json".to_string());
let id = proxy.id.clone();
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
assert!(pm.is_dynamic_proxy(&id));
assert!(!pm.is_dynamic_proxy("nonexistent"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_dynamic() {
let pm = ProxyManager::new();
let proxy = StoredProxy::new(
"Regular".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "1.2.3.4".to_string(),
port: 8080,
username: None,
password: None,
},
);
let id = proxy.id.clone();
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
let err = pm.resolve_dynamic_proxy(&id).await.unwrap_err();
assert!(err.contains("not a dynamic proxy"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_found() {
let pm = ProxyManager::new();
let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err();
assert!(err.contains("not found"));
}
}
+8 -6
View File
@@ -1062,14 +1062,16 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
// CONNECT requests need special handling for tunneling
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
// Wait for the stream to have readable data before attempting to read.
// This prevents read() from returning 0 on a fresh connection before
// the client's data arrives.
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
}
Ok(0) => {}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
+24 -12
View File
@@ -200,7 +200,7 @@ impl SettingsManager {
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::RngCore;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
@@ -390,7 +390,7 @@ impl SettingsManager {
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token_bytes: [u8; 32] = {
use rand::RngCore;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
@@ -734,11 +734,17 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to store API token: {e}"))?;
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_api_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.api_token = Some(t);
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
}
}
}
@@ -758,11 +764,17 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to store MCP token: {e}"))?;
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_mcp_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.mcp_token = Some(t);
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
}
}
}
+30 -1
View File
@@ -127,6 +127,14 @@ impl SyncClient {
}
pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> {
self.list_page(prefix, None).await
}
async fn list_page(
&self,
prefix: &str,
continuation_token: Option<String>,
) -> SyncResult<ListResponse> {
let response = self
.client
.post(self.url("list"))
@@ -134,7 +142,7 @@ impl SyncClient {
.json(&ListRequest {
prefix: prefix.to_string(),
max_keys: Some(1000),
continuation_token: None,
continuation_token,
})
.send()
.await
@@ -152,6 +160,27 @@ impl SyncClient {
.map_err(|e| SyncError::SerializationError(e.to_string()))
}
/// List all objects under a prefix, paginating through all results
pub async fn list_all(&self, prefix: &str) -> SyncResult<Vec<ListObject>> {
let mut all_objects = Vec::new();
let mut continuation_token: Option<String> = None;
loop {
let response = self.list_page(prefix, continuation_token).await?;
all_objects.extend(response.objects);
if !response.is_truncated {
break;
}
continuation_token = response.next_continuation_token;
if continuation_token.is_none() {
break;
}
}
Ok(all_objects)
}
pub async fn upload_bytes(
&self,
presigned_url: &str,
+284 -26
View File
@@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager;
use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
@@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool {
.any(|pattern| path.contains(pattern))
}
/// Checkpoint all SQLite WAL files in a profile directory.
///
/// When a browser crashes or is killed, SQLite WAL files may contain
/// uncommitted data (e.g. cookies, login data). Since WAL files are
/// excluded from sync, we must checkpoint them into the main database
/// files before generating the manifest to avoid data loss.
fn checkpoint_sqlite_wal_files(profile_dir: &Path) {
fn find_wal_files(dir: &Path, wal_files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
find_wal_files(&path, wal_files);
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with("-wal") {
wal_files.push(path);
}
}
}
}
let mut wal_files = Vec::new();
find_wal_files(profile_dir, &mut wal_files);
for wal_path in &wal_files {
// Only checkpoint non-empty WAL files
let is_non_empty = fs::metadata(wal_path).map(|m| m.len() > 0).unwrap_or(false);
if !is_non_empty {
continue;
}
// Derive the main database path by stripping the "-wal" suffix
let db_path_str = wal_path.to_string_lossy();
let db_path = PathBuf::from(db_path_str.strip_suffix("-wal").unwrap());
if !db_path.exists() {
continue;
}
match rusqlite::Connection::open(&db_path) {
Ok(conn) => match conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") {
Ok(_) => {
log::info!(
"Checkpointed WAL for: {}",
db_path.file_name().unwrap_or_default().to_string_lossy()
);
}
Err(e) => {
log::warn!("Failed to checkpoint WAL for {}: {}", db_path.display(), e);
}
},
Err(e) => {
log::warn!(
"Failed to open DB for WAL checkpoint {}: {}",
db_path.display(),
e
);
}
}
}
}
/// Resume state persisted to disk so interrupted syncs can continue
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SyncResumeState {
@@ -362,6 +426,10 @@ impl SyncEngine {
))
})?;
// Checkpoint any SQLite WAL files to ensure all data is in the main DB
// before we generate the manifest (WAL files are excluded from sync)
checkpoint_sqlite_wal_files(&profile_dir);
// Load or create hash cache
let cache_path = get_cache_path(&profile_dir);
let mut hash_cache = HashCache::load(&cache_path);
@@ -392,7 +460,9 @@ impl SyncEngine {
// Try to download remote manifest
let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
let remote_manifest = self.download_manifest(&remote_manifest_key).await?;
let remote_manifest = self
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
.await?;
// Compute diff
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
@@ -488,11 +558,29 @@ impl SyncEngine {
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If we recovered from an empty local state (downloaded everything from remote),
// regenerate the manifest from the actual files now on disk so we don't
// overwrite the remote manifest with an empty one.
let final_manifest = if local_manifest.files.is_empty() && !diff.files_to_download.is_empty() {
let mut new_cache = HashCache::load(&cache_path);
let mut regenerated = generate_manifest(&profile_id, &profile_dir, &mut new_cache)?;
new_cache.save(&cache_path)?;
regenerated.encrypted = encryption_key.is_some();
regenerated
} else {
let mut m = local_manifest;
m.encrypted = encryption_key.is_some();
m
};
// Upload manifest.json last for atomicity
let mut final_manifest = local_manifest;
final_manifest.encrypted = encryption_key.is_some();
self
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
.upload_manifest(
&profile_id,
&final_manifest,
encryption_key.as_ref(),
&key_prefix,
)
.await?;
// Sync completed successfully — clean up resume state
@@ -533,7 +621,11 @@ impl SyncEngine {
Ok(())
}
async fn download_manifest(&self, key: &str) -> SyncResult<Option<SyncManifest>> {
async fn download_manifest(
&self,
key: &str,
encryption_key: Option<&[u8; 32]>,
) -> SyncResult<Option<SyncManifest>> {
let stat = self.client.stat(key).await?;
if !stat.exists {
return Ok(None);
@@ -542,30 +634,58 @@ impl SyncEngine {
let presign = self.client.presign_download(key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let manifest: SyncManifest = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse manifest: {e}")))?;
// Try parsing as plaintext JSON first (unencrypted or backwards-compatible)
if let Ok(manifest) = serde_json::from_slice::<SyncManifest>(&data) {
return Ok(Some(manifest));
}
Ok(Some(manifest))
// If plaintext parse failed and we have an encryption key, try decrypting
if let Some(key) = encryption_key {
let decrypted = encryption::decrypt_bytes(key, &data)
.map_err(|e| SyncError::InvalidData(format!("Failed to decrypt manifest: {e}")))?;
let manifest: SyncManifest = serde_json::from_slice(&decrypted).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse decrypted manifest: {e}"))
})?;
return Ok(Some(manifest));
}
Err(SyncError::SerializationError(
"Failed to parse manifest (not valid JSON and no encryption key available)".to_string(),
))
}
async fn upload_manifest(
&self,
profile_id: &str,
manifest: &SyncManifest,
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
) -> SyncResult<()> {
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize manifest: {e}")))?;
let upload_data = if let Some(key) = encryption_key {
encryption::encrypt_bytes(key, json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to encrypt manifest: {e}")))?
} else {
json.into_bytes()
};
let content_type = if encryption_key.is_some() {
"application/octet-stream"
} else {
"application/json"
};
let remote_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &upload_data, Some(content_type))
.await?;
Ok(())
@@ -2059,16 +2179,9 @@ impl SyncEngine {
return Ok(true);
}
// Download manifest
let manifest = self.download_manifest(&manifest_key).await?;
let Some(manifest) = manifest else {
return Err(SyncError::InvalidData(
"Remote manifest not found".to_string(),
));
};
// If remote manifest is encrypted, we need the E2E password
let encryption_key = if manifest.encrypted {
// Derive encryption key before downloading manifest if profile uses encrypted sync.
// The manifest itself may be encrypted (new behavior) or plaintext (backwards compat).
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
.ok_or_else(|| {
@@ -2087,6 +2200,16 @@ impl SyncEngine {
None
};
// Download manifest (may be encrypted for e2e profiles)
let manifest = self
.download_manifest(&manifest_key, encryption_key.as_ref())
.await?;
let Some(manifest) = manifest else {
return Err(SyncError::InvalidData(
"Remote manifest not found".to_string(),
));
};
// Ensure profile directory exists
fs::create_dir_all(&profile_dir).map_err(|e| {
SyncError::IoError(format!(
@@ -2165,14 +2288,14 @@ impl SyncEngine {
) -> SyncResult<Vec<String>> {
log::info!("Checking for missing synced profiles...");
// List personal profiles from S3
let list_response = self.client.list("profiles/").await?;
// List all personal profiles from S3 (paginated)
let all_objects = self.client.list_all("profiles/").await?;
let mut downloaded: Vec<String> = Vec::new();
// Extract unique profile IDs with their key prefix
let mut profiles_to_check: HashMap<String, String> = HashMap::new();
for obj in list_response.objects {
for obj in all_objects {
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
if let Some(profile_id) = obj
.key
@@ -2189,8 +2312,8 @@ impl SyncEngine {
if let Some(team_id) = &auth.user.team_id {
let team_prefix = format!("teams/{}/", team_id);
let team_list_key = format!("{}profiles/", team_prefix);
if let Ok(team_list) = self.client.list(&team_list_key).await {
for obj in team_list.objects {
if let Ok(team_objects) = self.client.list_all(&team_list_key).await {
for obj in team_objects {
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
if let Some(profile_id) = obj
.key
@@ -3341,3 +3464,138 @@ pub async fn set_extension_group_sync_enabled(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_checkpoint_sqlite_wal_files() {
let temp_dir = tempfile::TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
// Create a SQLite database in WAL mode and insert data.
// Use std::mem::forget to prevent the connection destructor from running,
// which simulates a browser crash where WAL is not checkpointed.
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
conn
.execute(
"CREATE TABLE cookies (id INTEGER PRIMARY KEY, value TEXT)",
[],
)
.unwrap();
conn
.execute(
"INSERT INTO cookies (value) VALUES ('session_token_123')",
[],
)
.unwrap();
// Leak the connection to prevent auto-checkpoint on drop
std::mem::forget(conn);
}
// Verify WAL file exists and has data
let wal_path = temp_dir.path().join("test.db-wal");
assert!(wal_path.exists(), "WAL file should exist");
let wal_size = fs::metadata(&wal_path).unwrap().len();
assert!(wal_size > 0, "WAL file should be non-empty");
// Run checkpoint
checkpoint_sqlite_wal_files(temp_dir.path());
// After checkpoint, WAL should be truncated (empty)
let wal_size_after = fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
assert_eq!(
wal_size_after, 0,
"WAL should be truncated after checkpoint"
);
// Verify data is still accessible from the main database
let conn = rusqlite::Connection::open(&db_path).unwrap();
let value: String = conn
.query_row("SELECT value FROM cookies WHERE id = 1", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(value, "session_token_123");
}
#[test]
fn test_checkpoint_handles_missing_db() {
let temp_dir = tempfile::TempDir::new().unwrap();
// Create a WAL file without a corresponding database
let wal_path = temp_dir.path().join("missing.db-wal");
fs::write(&wal_path, b"fake wal data").unwrap();
// Should not panic
checkpoint_sqlite_wal_files(temp_dir.path());
}
#[test]
fn test_checkpoint_skips_empty_wal() {
let temp_dir = tempfile::TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
// Create a database and checkpoint immediately (WAL is empty)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", [])
.unwrap();
}
// Create an empty WAL file
let wal_path = temp_dir.path().join("test.db-wal");
fs::write(&wal_path, b"").unwrap();
// Should skip empty WAL without error
checkpoint_sqlite_wal_files(temp_dir.path());
}
#[test]
fn test_checkpoint_nested_directories() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nested_dir = temp_dir.path().join("profile").join("Default");
fs::create_dir_all(&nested_dir).unwrap();
let db_path = nested_dir.join("Cookies");
// Create a database with WAL data, leak connection to simulate crash
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
conn
.execute(
"CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT)",
[],
)
.unwrap();
conn
.execute(
"INSERT INTO cookies VALUES ('.example.com', 'session', 'abc')",
[],
)
.unwrap();
std::mem::forget(conn);
}
let wal_path = nested_dir.join("Cookies-wal");
assert!(wal_path.exists());
// Checkpoint from the top-level directory
checkpoint_sqlite_wal_files(temp_dir.path());
// Verify data is in the main database
let conn = rusqlite::Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM cookies", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
}
+59
View File
@@ -408,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let remote_files: HashMap<&str, &ManifestFileEntry> =
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
// Safety: if local is empty but remote has files, always download from remote.
// This prevents data loss when profile data files are deleted but metadata
// survives — the newly generated manifest would have updated_at=NOW, which
// would appear "newer" and cause all remote files to be deleted.
if local.files.is_empty() && !remote.files.is_empty() {
log::info!(
"Local manifest is empty but remote has {} files — downloading from remote to recover",
remote.files.len()
);
diff.files_to_download = remote.files.clone();
return diff;
}
// Compare timestamps to determine direction
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
@@ -738,4 +751,50 @@ mod tests {
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted);
}
#[test]
fn test_compute_diff_empty_local_downloads_from_remote() {
// When local has no files but remote does, always download from remote.
// This prevents data loss when profile data is deleted but metadata survives.
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
exclude_globs: vec![],
files: vec![],
encrypted: false,
};
let remote = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "Cookies".to_string(),
size: 100,
mtime: 1000,
hash: "abc".to_string(),
},
ManifestFileEntry {
path: "Local State".to_string(),
size: 200,
mtime: 1000,
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
// Must download all remote files, NOT delete them
assert_eq!(diff.files_to_download.len(), 2);
assert!(diff.files_to_upload.is_empty());
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
}
+101 -86
View File
@@ -396,97 +396,112 @@ impl SyncScheduler {
ready
};
// Mark all profiles as in-flight and filter out duplicates
let mut to_sync = Vec::new();
for profile_id in profiles_to_sync {
// Mark as in-flight to prevent duplicate syncs
{
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
}
in_flight.insert(profile_id.clone());
}
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
// Remove from in-flight
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
};
let result = match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => engine.sync_profile(app_handle, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
// Remove from in-flight and check if sync just completed
let sync_just_completed = {
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
// If this was the last in-flight profile and there are no pending profiles, sync just completed
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
in_flight.insert(profile_id.clone());
to_sync.push(profile_id);
}
// Trigger cleanup after sync completes if this was the last profile
if sync_just_completed {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
// Sync all profiles in parallel
let mut sync_set = tokio::task::JoinSet::new();
for profile_id in to_sync {
let app = app_handle.clone();
let in_flight = self.in_flight_profiles.clone();
sync_set.spawn(async move {
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
return;
};
let result = match SyncEngine::create_from_settings(&app).await {
Ok(engine) => engine.sync_profile(&app, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
{
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
}
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
});
}
// Wait for all parallel syncs to finish
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
}
}
// Trigger cleanup if everything is done
let all_done = {
let in_flight = self.in_flight_profiles.lock().await;
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
if all_done {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -143,12 +143,7 @@ impl VersionUpdater {
pub async fn check_and_run_startup_update(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Only run if an update is actually needed
if !Self::should_run_background_update() {
log::debug!("No startup version update needed");
return Ok(());
}
// Always check for updates on launch
if let Some(ref app_handle) = self.app_handle {
log::info!("Running startup version update...");
+6 -6
View File
@@ -73,11 +73,11 @@ struct WgRxToken {
}
impl RxToken for WgRxToken {
fn consume<R, F>(mut self, f: F) -> R
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
F: FnOnce(&[u8]) -> R,
{
f(&mut self.data)
f(&self.data)
}
}
@@ -173,7 +173,7 @@ fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
))
}
std::net::IpAddr::V6(v6) => {
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
let smol_ip = smoltcp::wire::Ipv6Address::from(v6.octets());
Ok((
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
IpAddress::Ipv6(smol_ip),
@@ -331,7 +331,7 @@ impl WireGuardSocks5Server {
// Set default gateway
match local_ip {
IpAddress::Ipv4(v4) => {
let octets = v4.as_bytes();
let octets = v4.octets();
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
iface
.routes_mut()
@@ -523,7 +523,7 @@ impl WireGuardSocks5Server {
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
}
std::net::IpAddr::V6(v6) => {
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()))
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from(v6.octets()))
}
};
+1 -1
View File
@@ -6,7 +6,7 @@ use aes_gcm::{
Aes256Gcm, Nonce,
};
use chrono::Utc;
use rand::Rng;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
+28 -19
View File
@@ -514,6 +514,15 @@ impl WayfernManager {
args.push(format!("--load-extension={}", extension_paths.join(",")));
}
// Pass wayfern token as CLI flag so the browser can gate CDP features
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
} else {
log::warn!("No wayfern token available — CDP gated methods will be blocked");
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time
@@ -674,25 +683,6 @@ impl WayfernManager {
}
}
// Close the debugging port to prevent localhost port-scan detection.
// Reopen on a random high port after 5s so we can still manage the browser.
let reopen_port = port; // Reopen on same port for find_wayfern_by_profile recovery
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
match self
.send_cdp_command(
ws_url,
"Wayfern.closeDebuggingPort",
json!({ "reopenPort": reopen_port, "reopenDelayMs": 30000 }),
)
.await
{
Ok(_) => log::info!("Closed debugging port, will reopen on {reopen_port} after 30s"),
Err(e) => log::warn!("Failed to close debugging port: {e}"),
}
}
}
let id = uuid::Uuid::new_v4().to_string();
let instance = WayfernInstance {
id: id.clone(),
@@ -783,6 +773,25 @@ impl WayfernManager {
Ok(())
}
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.16.1",
"version": "0.17.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+177
View File
@@ -1121,3 +1121,180 @@ async fn test_no_bypass_rules_all_through_upstream(
Ok(())
}
/// Start a minimal SOCKS5 proxy that tunnels connections to the real destination.
/// Returns (port, JoinHandle).
async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// SOCKS5 handshake: client sends version + methods
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 2 || buf[0] != 0x05 {
return;
}
// Reply: version 5, no auth required
client.write_all(&[0x05, 0x00]).await.ok();
// Read connect request: VER CMD RSV ATYP DST.ADDR DST.PORT
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 7 || buf[1] != 0x01 {
client
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
let (target_host, target_port) = match buf[3] {
0x01 => {
// IPv4
if n < 10 {
return;
}
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(ip, port)
}
0x03 => {
// Domain
let domain_len = buf[4] as usize;
if n < 5 + domain_len + 2 {
return;
}
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port = u16::from_be_bytes([buf[5 + domain_len], buf[6 + domain_len]]);
(domain, port)
}
_ => return,
};
// Connect to target
let target =
match tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)).await {
Ok(t) => t,
Err(_) => {
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
};
// Success reply
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await
.ok();
// Bidirectional relay
let (mut cr, mut cw) = tokio::io::split(client);
let (mut tr, mut tw) = tokio::io::split(target);
tokio::select! {
_ = tokio::io::copy(&mut cr, &mut tw) => {}
_ = tokio::io::copy(&mut tr, &mut cw) => {}
}
});
}
});
sleep(Duration::from_millis(100)).await;
(port, handle)
}
/// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy.
/// Starts a mock SOCKS5 server, a mock HTTP target server,
/// then routes requests through donut-proxy -> SOCKS5 -> target.
#[tokio::test]
#[serial]
async fn test_local_proxy_with_socks5_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a mock HTTP server as the final destination
let (target_port, target_handle) = start_mock_http_server("SOCKS5-TARGET-RESPONSE").await;
println!("Mock target HTTP server on port {target_port}");
// Start a mock SOCKS5 proxy
let (socks_port, socks_handle) = start_mock_socks5_server().await;
println!("Mock SOCKS5 server on port {socks_port}");
// Helper to start a socks5 proxy
async fn start_socks5_proxy(
binary_path: &std::path::PathBuf,
socks_port: u16,
) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
let output = TestUtils::execute_command(
binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&socks_port.to_string(),
"--type",
"socks5",
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let id = config["id"].as_str().unwrap().to_string();
let port = config["localPort"].as_u64().unwrap() as u16;
// Wait for proxy to be fully ready by verifying it accepts and responds
for _ in 0..20 {
sleep(Duration::from_millis(100)).await;
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
break;
}
}
// Extra settle time for the accept loop to be fully initialized
sleep(Duration::from_millis(200)).await;
Ok((id, port))
}
// Test 1: HTTP request through donut-proxy -> SOCKS5 -> target
let (proxy_id, local_port) = start_socks5_proxy(&binary_path, socks_port).await?;
tracker.track_proxy(proxy_id);
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = vec![0u8; 8192];
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut response))
.await
.map_err(|_| "HTTP request through SOCKS5 timed out")?
.map_err(|e| format!("Read error: {e}"))?;
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains("SOCKS5-TARGET-RESPONSE"),
"HTTP request should be tunneled through SOCKS5 to target, got: {}",
&response_str[..response_str.len().min(500)]
);
println!("SOCKS5 upstream proxy test passed");
tracker.cleanup_all().await;
target_handle.abort();
socks_handle.abort();
Ok(())
}
+207
View File
@@ -841,3 +841,210 @@ async fn test_profile_bypass_rules_sync() {
client.delete(&test_key, None).await.unwrap();
client.delete(&empty_key, None).await.unwrap();
}
#[tokio::test]
async fn test_encrypted_profile_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let temp_dir = TempDir::new().unwrap();
let profile_id = uuid::Uuid::new_v4().to_string();
let test_key = format!("profiles/{}.tar.gz.enc", profile_id);
let bundle = create_test_profile_bundle(temp_dir.path());
let salt = generate_salt();
let password = "test-e2e-encryption-password";
let key = derive_profile_key(password, &salt).unwrap();
let encrypted = encrypt_bytes(&key, &bundle).unwrap();
assert_ne!(
encrypted, bundle,
"Encrypted data should differ from plaintext"
);
assert!(
encrypted.len() > bundle.len(),
"Encrypted data includes nonce + auth tag overhead"
);
let presign = client
.presign_upload(&test_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign.url, &encrypted, "application/octet-stream")
.await
.unwrap();
let stat = client.stat(&test_key).await.unwrap();
assert!(stat.exists);
assert_eq!(stat.size, Some(encrypted.len() as u64));
let download_presign = client.presign_download(&test_key).await.unwrap();
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
assert_eq!(downloaded.len(), encrypted.len());
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, bundle,
"Decrypted content should match original bundle"
);
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let metadata = extract_bundle(&decrypted, &extract_dir);
assert_eq!(metadata["id"], "test-profile-id");
assert_eq!(metadata["name"], "Test Profile");
assert_eq!(metadata["browser"], "chromium");
assert_eq!(metadata["version"], "120.0.0");
assert!(metadata["sync_enabled"].as_bool().unwrap());
let tags = metadata["tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0], "test");
assert_eq!(tags[1], "e2e");
let test_file = extract_dir.join("profile").join("test_file.txt");
assert!(test_file.exists());
assert_eq!(fs::read_to_string(test_file).unwrap(), "test content");
let wrong_key = derive_profile_key("wrong-password", &salt).unwrap();
assert!(
decrypt_bytes(&wrong_key, &downloaded).is_err(),
"Decryption with wrong key should fail"
);
let different_salt = generate_salt();
let wrong_salt_key = derive_profile_key(password, &different_salt).unwrap();
assert!(
decrypt_bytes(&wrong_salt_key, &downloaded).is_err(),
"Decryption with key derived from wrong salt should fail"
);
client.delete(&test_key, None).await.unwrap();
let final_stat = client.stat(&test_key).await.unwrap();
assert!(!final_stat.exists);
}
#[tokio::test]
async fn test_encrypted_delta_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let profile_id = uuid::Uuid::new_v4().to_string();
let salt = generate_salt();
let password = "delta-sync-test-password";
let key = derive_profile_key(password, &salt).unwrap();
let file1_key = format!("profiles/{}/files/file1.txt.enc", profile_id);
let file2_key = format!("profiles/{}/files/file2.txt.enc", profile_id);
let file3_key = format!("profiles/{}/files/file3.txt.enc", profile_id);
let content1 = b"file one content";
let content2 = b"file two content";
let content3 = b"file three content";
let encrypted1 = encrypt_bytes(&key, content1).unwrap();
let encrypted2 = encrypt_bytes(&key, content2).unwrap();
let encrypted3 = encrypt_bytes(&key, content3).unwrap();
let presign1 = client
.presign_upload(&file1_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign1.url, &encrypted1, "application/octet-stream")
.await
.unwrap();
let presign2 = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign2.url, &encrypted2, "application/octet-stream")
.await
.unwrap();
let presign3 = client
.presign_upload(&file3_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign3.url, &encrypted3, "application/octet-stream")
.await
.unwrap();
for (file_key, expected_content) in [
(&file1_key, content1.as_slice()),
(&file2_key, content2.as_slice()),
(&file3_key, content3.as_slice()),
] {
let dl_presign = client.presign_download(file_key).await.unwrap();
let downloaded = client.download_bytes(&dl_presign.url).await.unwrap();
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, expected_content,
"Decrypted content mismatch for {file_key}"
);
}
let stat1_before = client.stat(&file1_key).await.unwrap();
let stat2_before = client.stat(&file2_key).await.unwrap();
let stat3_before = client.stat(&file3_key).await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let updated_content2 = b"file two content -- updated with new data";
let encrypted2_updated = encrypt_bytes(&key, updated_content2).unwrap();
let presign2_update = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(
&presign2_update.url,
&encrypted2_updated,
"application/octet-stream",
)
.await
.unwrap();
let stat2_after = client.stat(&file2_key).await.unwrap();
assert_ne!(
stat2_before.size, stat2_after.size,
"File2 size should have changed after update"
);
let stat1_after = client.stat(&file1_key).await.unwrap();
let stat3_after = client.stat(&file3_key).await.unwrap();
assert_eq!(
stat1_before.size, stat1_after.size,
"File1 should be unchanged"
);
assert_eq!(
stat3_before.size, stat3_after.size,
"File3 should be unchanged"
);
let dl_presign2 = client.presign_download(&file2_key).await.unwrap();
let downloaded2 = client.download_bytes(&dl_presign2.url).await.unwrap();
let decrypted2 = decrypt_bytes(&key, &downloaded2).unwrap();
assert_eq!(
decrypted2,
updated_content2.to_vec(),
"Updated file2 should decrypt to new content"
);
client.delete(&file1_key, None).await.unwrap();
client.delete(&file2_key, None).await.unwrap();
client.delete(&file3_key, None).await.unwrap();
}
+30 -51
View File
@@ -29,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -39,6 +40,7 @@ import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -57,14 +59,7 @@ import type {
WayfernConfig,
} from "@/types";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface PendingUrl {
id: string;
@@ -97,6 +92,11 @@ export default function Home() {
const { vpnConfigs } = useVpnEvents();
// Synchronizer sessions
const { getProfileSyncInfo } = useSyncSessions();
const [syncLeaderProfile, setSyncLeaderProfile] =
useState<BrowserProfile | null>(null);
// Wayfern terms and commercial trial hooks
const {
termsAccepted,
@@ -809,6 +809,7 @@ export default function Home() {
useEffect(() => {
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
(async () => {
try {
unlistenStatus = await listen<{
@@ -822,19 +823,15 @@ export default function Home() {
const profile = profiles.find((p) => p.id === profile_id);
const name = profile_name || profile?.name || "Unknown";
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: Number.POSITIVE_INFINITY,
onCancel: () => dismissToast(toastId),
});
} else if (status === "synced") {
if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id);
showSuccessToast(`Profile '${name}' synced successfully`);
}
} else if (status === "error") {
dismissToast(toastId);
profilesWithTransfer.delete(profile_id);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
);
@@ -863,6 +860,7 @@ export default function Home() {
payload.phase === "uploading" ||
payload.phase === "downloading"
) {
profilesWithTransfer.add(payload.profile_id);
showSyncProgressToast(
name,
{
@@ -943,37 +941,6 @@ export default function Home() {
profiles.length,
]);
// Show deprecation warning for unsupported profiles (with names)
useEffect(() => {
if (profiles.length === 0) return;
const deprecatedProfiles = profiles.filter(
(p) => p.release_type === "nightly" && p.browser !== "firefox-developer",
);
if (deprecatedProfiles.length > 0) {
const deprecatedNames = deprecatedProfiles.map((p) => p.name).join(", ");
// Use a stable id to avoid duplicate toasts on re-renders
showToast({
id: "deprecated-profiles-warning",
type: "error",
title: "Some profiles will be deprecated soon",
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
duration: 15000,
action: {
label: "Learn more",
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions/66",
});
window.dispatchEvent(event);
},
},
});
}
}, [profiles]);
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => {
if (profiles.length === 0) return;
@@ -1126,6 +1093,8 @@ export default function Home() {
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
/>
</div>
</main>
@@ -1163,6 +1132,7 @@ export default function Home() {
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
@@ -1329,13 +1299,14 @@ export default function Home() {
onAccepted={checkTerms}
/>
{/* Commercial Trial Modal - shown once when trial expires */}
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
<CommercialTrialModal
isOpen={
!termsLoading &&
termsAccepted === true &&
trialStatus?.type === "Expired" &&
!trialAcknowledged
!trialAcknowledged &&
!crossOsUnlocked
}
onClose={checkTrialStatus}
/>
@@ -1355,6 +1326,14 @@ export default function Home() {
windowResizeWarningResolver.current = null;
}}
/>
<SyncFollowerDialog
isOpen={syncLeaderProfile !== null}
onClose={() => setSyncLeaderProfile(null)}
leaderProfile={syncLeaderProfile}
allProfiles={profiles}
runningProfiles={runningProfiles}
/>
</div>
);
}
+2 -2
View File
@@ -462,8 +462,8 @@ export function CookieManagementDialog({
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
<div className="p-4 rounded-lg bg-success/10">
<div className="font-medium text-success">
Successfully imported {importResult.cookies_imported}{" "}
cookies ({importResult.cookies_replaced} replaced)
</div>
+1 -1
View File
@@ -91,7 +91,7 @@ export function CreateGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+16 -63
View File
@@ -67,14 +67,7 @@ const getCurrentOS = (): CamoufoxOS => {
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -103,24 +96,12 @@ interface BrowserOption {
const browserOptions: BrowserOption[] = [
{
value: "firefox",
label: "Firefox",
value: "camoufox",
label: "Camoufox",
},
{
value: "firefox-developer",
label: "Firefox Developer Edition",
},
{
value: "chromium",
label: "Chromium",
},
{
value: "brave",
label: "Brave",
},
{
value: "zen",
label: "Zen Browser",
value: "wayfern",
label: "Wayfern",
},
];
@@ -254,23 +235,9 @@ export function CreateProfileDialog({
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
if (browser === "camoufox" || browser === "wayfern") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
} else if (browser === "firefox-developer") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.nightly)
filtered.nightly = rawReleaseTypes.nightly;
setReleaseTypes(filtered);
} else {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable) filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
setReleaseTypesError(null);
}
} catch (error) {
@@ -282,11 +249,7 @@ export function CreateProfileDialog({
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
if (browser === "firefox-developer") {
fallback.nightly = latest;
} else {
fallback.stable = latest;
}
fallback.stable = latest;
setReleaseTypes(fallback);
setReleaseTypesError(null);
} else if (loadingBrowserRef.current === browser) {
@@ -351,17 +314,9 @@ export function CreateProfileDialog({
// Helper function to get the best available version respecting rules
const getBestAvailableVersion = useCallback(
(browserType?: string) => {
(_browserType?: string) => {
if (!releaseTypes) return null;
// Firefox Developer Edition: nightly-only
if (browserType === "firefox-developer" && releaseTypes.nightly) {
return {
version: releaseTypes.nightly,
releaseType: "nightly" as const,
};
}
// All others: stable-only
if (releaseTypes.stable) {
return { version: releaseTypes.stable, releaseType: "stable" as const };
}
@@ -379,11 +334,9 @@ export function CreateProfileDialog({
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
if (browserDownloaded.length > 0) {
const fallbackVersion = browserDownloaded[0];
const releaseType =
browserType === "firefox-developer" ? "nightly" : "stable";
return {
version: fallbackVersion,
releaseType: releaseType as "stable" | "nightly",
releaseType: "stable" as const,
};
}
return null;
@@ -772,8 +725,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Wayfern is not available on your platform
yet.
</p>
@@ -874,8 +827,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Camoufox is not available on your platform
yet.
</p>
@@ -933,7 +886,7 @@ export function CreateProfileDialog({
)}
{crossOsUnlocked && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
+1 -1
View File
@@ -347,7 +347,7 @@ export function UnifiedToast(props: ToastProps) {
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
Extracting browser files...
Extracting browser files... Please do not close the app.
</p>
)}
{stage === "verifying" && (
+2 -2
View File
@@ -117,7 +117,7 @@ function DataTableActionBarAction({
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="border bg-accent font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
@@ -155,7 +155,7 @@ function DataTableActionBarSelection<TData>({
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>Clear selection</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
+2 -2
View File
@@ -162,7 +162,7 @@ export function DeleteGroupDialog({
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
className="text-sm text-red-600"
className="text-sm text-destructive"
>
Delete profiles along with the group
</Label>
@@ -181,7 +181,7 @@ export function DeleteGroupDialog({
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+1 -1
View File
@@ -101,7 +101,7 @@ export function EditGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -55,10 +55,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -66,18 +66,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function GroupAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+145 -143
View File
@@ -49,10 +49,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: group.last_sync
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -60,18 +60,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
@@ -236,152 +240,150 @@ export function GroupManagementDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className="overflow-y-auto flex-1">
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the button
above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</ScrollArea>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
+178 -15
View File
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
@@ -24,6 +25,148 @@ import { Input } from "./ui/input";
import { ProBadge } from "./ui/pro-badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
function useLogoEasterEgg() {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const triggerFall = useCallback(() => {
const el = logoRef.current;
if (!el || isFalling) return;
setIsFalling(true);
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const floorY = window.innerHeight;
const leftWall = 0;
const rightWall = window.innerWidth;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.pointerEvents = "none";
clone.style.margin = "0";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = -500;
let vx = -INITIAL_HORIZONTAL_SPEED;
let rotation = 0;
let lastTime = performance.now();
const animate = (time: number) => {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
// Floor bounce
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
vy = -Math.abs(vy) * BOUNCE_DAMPING;
} else {
vy = -MIN_BOUNCE_VELOCITY * 3;
}
}
// Left wall bounce only — right wall lets it fly off screen
const currentLeft = startX + x;
if (currentLeft <= leftWall && vx < 0) {
x = leftWall - startX;
vx = Math.abs(vx) * 1.1;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
const currentTop = startY + y;
const offScreenRight = startX + x > rightWall + 50;
const offScreenBottom = currentTop > floorY + 100;
const offScreenTop = currentTop + rect.height < -200;
if (offScreenRight || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
triggerFall();
} else {
setWobbleKey((k) => k + 1);
}
}, [isFalling, isHidden, triggerFall]);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
};
}
type Props = {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
@@ -52,24 +195,44 @@ const HomeHeader = ({
crossOsUnlocked = false,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: "https://donutbrowser.com",
});
window.dispatchEvent(event);
};
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
<button
type="button"
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
onClick={handleLogoClick}
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => setIsPressed(true)}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
+409 -294
View File
@@ -2,10 +2,12 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,19 +25,29 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
if (["firefox", "firefox-developer", "zen"].includes(browser))
return "camoufox";
return "wayfern";
};
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
@@ -45,6 +57,12 @@ export function ImportProfileDialog({
const [importMode, setImportMode] = useState<"auto-detect" | "manual">(
"auto-detect",
);
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
"select",
);
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
// Auto-detect state
const [selectedDetectedProfile, setSelectedDetectedProfile] = useState<
@@ -61,6 +79,7 @@ export function ImportProfileDialog({
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
const { storedProxies } = useProxyEvents();
const importableBrowsers = supportedBrowsers;
@@ -72,14 +91,11 @@ export function ImportProfileDialog({
);
setDetectedProfiles(profiles);
// Auto-switch to manual mode if no profiles detected
if (profiles.length === 0) {
setImportMode("manual");
} else {
// Auto-select first profile if available
setSelectedDetectedProfile(profiles[0].path);
// Generate default name from the detected profile
const profile = profiles[0];
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
@@ -93,6 +109,10 @@ export function ImportProfileDialog({
}
}, []);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const handleBrowseFolder = async () => {
try {
const selected = await open({
@@ -110,40 +130,65 @@ export function ImportProfileDialog({
}
};
const handleAutoDetectImport = useCallback(async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
const handleImport = useCallback(async () => {
let sourcePath: string;
let browserType: string;
let newProfileName: string;
if (importMode === "auto-detect") {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
sourcePath = profile.path;
browserType = profile.browser;
newProfileName = autoDetectProfileName.trim();
} else {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
sourcePath = manualProfilePath.trim();
browserType = manualBrowserType;
newProfileName = manualProfileName.trim();
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
const mappedBrowser =
importMode === "auto-detect" && selectedProfile
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
: getMappedBrowser(browserType);
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: profile.path,
browserType: profile.browser,
newProfileName: autoDetectProfileName.trim(),
sourcePath,
browserType,
newProfileName,
proxyId: selectedProxyId ?? null,
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
});
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
toast.success(`Successfully imported profile "${newProfileName}"`);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(profile.browser);
const browserDisplayName = getBrowserDisplayName(browserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
@@ -157,63 +202,30 @@ export function ImportProfileDialog({
setIsImporting(false);
}
}, [
importMode,
selectedDetectedProfile,
autoDetectProfileName,
detectedProfiles,
manualBrowserType,
manualProfilePath,
manualProfileName,
selectedProxyId,
camoufoxConfig,
wayfernConfig,
onClose,
selectedProfile,
]);
const handleManualImport = useCallback(async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: manualProfilePath.trim(),
browserType: manualBrowserType,
newProfileName: manualProfileName.trim(),
});
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
const handleClose = () => {
setCurrentStep("select");
setCamoufoxConfig({});
setWayfernConfig({});
setSelectedProxyId(undefined);
setSelectedDetectedProfile(null);
setAutoDetectProfileName("");
setManualBrowserType(null);
setManualProfilePath("");
setManualProfileName("");
// Only reset to auto-detect if there are profiles available
if (detectedProfiles.length > 0) {
setImportMode("auto-detect");
} else {
@@ -222,7 +234,6 @@ export function ImportProfileDialog({
onClose();
};
// Update auto-detect profile name when selection changes
useEffect(() => {
if (selectedDetectedProfile) {
const profile = detectedProfiles.find(
@@ -236,9 +247,38 @@ export function ImportProfileDialog({
}
}, [selectedDetectedProfile, detectedProfiles]);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const currentMappedBrowser = useMemo(() => {
if (importMode === "auto-detect" && selectedProfile) {
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
}
if (importMode === "manual" && manualBrowserType) {
return manualBrowserType as "camoufox" | "wayfern";
}
return null;
}, [importMode, selectedProfile, manualBrowserType]);
const canProceedToNext = useMemo(() => {
if (importMode === "auto-detect") {
return (
!isLoading &&
!!selectedDetectedProfile &&
!!autoDetectProfileName.trim()
);
}
return (
!!manualBrowserType &&
!!manualProfilePath.trim() &&
!!manualProfileName.trim()
);
}, [
importMode,
isLoading,
selectedDetectedProfile,
autoDetectProfileName,
manualBrowserType,
manualProfilePath,
manualProfileName,
]);
useEffect(() => {
if (isOpen) {
@@ -254,247 +294,322 @@ export function ImportProfileDialog({
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{/* Auto-Detect Mode */}
{importMode === "auto-detect" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Detected Browser Profiles</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in custom
locations.
</p>
</div>
) : (
{importMode === "auto-detect" && (
<div className="space-y-4">
<div>
<Label htmlFor="detected-profile-select" className="mb-2">
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
return (
<SelectItem key={profile.path} value={profile.path}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<h3 className="text-lg font-medium">
Detected Browser Profiles
</h3>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
)}
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in
custom locations.
</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label
htmlFor="detected-profile-select"
className="mb-2"
>
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return (
<SelectItem
key={profile.path}
value={profile.path}
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{" "}
{getBrowserDisplayName(
profile.mapped_browser,
)}
</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
</p>
</div>
)}
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
)}
</div>
)}
{importMode === "manual" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* Manual Import Mode */}
{importMode === "manual" && (
{currentStep === "configure" && currentMappedBrowser && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<Alert>
<AlertDescription>
This profile will be imported as a{" "}
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
profile.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
<div>
<Label className="mb-2">Proxy (Optional)</Label>
<Select
value={selectedProxyId ?? "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? undefined : value);
}}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentMappedBrowser === "camoufox" ? (
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={(key, value) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleAutoDetectImport();
}}
disabled={
!selectedDetectedProfile ||
!autoDetectProfileName.trim() ||
isLoading
}
>
Import
</LoadingButton>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
setCurrentStep("configure");
}}
>
Next
</RippleButton>
</>
) : (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleManualImport();
}}
disabled={
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
}
>
Import
</LoadingButton>
<>
<RippleButton
variant="outline"
onClick={() => {
setCurrentStep("select");
}}
>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleImport();
}}
>
Import
</LoadingButton>
</>
)}
</DialogFooter>
</DialogContent>
+7 -29
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -42,6 +43,7 @@ export function IntegrationsDialog({
isOpen,
onClose,
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
api_enabled: false,
api_port: 10108,
@@ -320,7 +322,7 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
Allow AI assistants like Claude Desktop to control browsers.
{!termsAccepted && (
<span className="ml-1 text-orange-600">
<span className="ml-1 text-warning">
(Accept Wayfern terms in Settings first)
</span>
)}
@@ -329,20 +331,7 @@ export function IntegrationsDialog({
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
Claude Desktop Configuration
</Label>
<p className="text-xs text-muted-foreground">
Copy this configuration to your Claude Desktop config file
at{" "}
<code className="bg-muted px-1 rounded">
~/.config/claude/claude_desktop_config.json
</code>
</p>
</div>
<div className="space-y-3 p-4 rounded-md border bg-muted/40">
<div className="relative">
<pre className="p-3 text-xs font-mono rounded-md bg-background border overflow-x-auto whitespace-pre">
{showMcpToken
@@ -369,20 +358,9 @@ export function IntegrationsDialog({
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Available Tools
</Label>
<ul className="list-disc ml-5 space-y-0.5 text-xs text-muted-foreground">
<li>list_profiles - List browser profiles</li>
<li>run_profile - Launch a browser</li>
<li>kill_profile - Stop a running browser</li>
<li>get_profile_status - Check if browser is running</li>
<li>list_groups, create_group, etc. - Manage groups</li>
<li>list_proxies, create_proxy, etc. - Manage proxies</li>
</ul>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpCopyHint")}
</p>
</div>
)}
</TabsContent>
+5 -5
View File
@@ -116,7 +116,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
@@ -129,8 +129,8 @@ export function PermissionDialog({
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
Permission granted! Browsers launched from Donut Browser can
now access your {permissionType}.
</p>
@@ -138,8 +138,8 @@ export function PermissionDialog({
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
<p className="text-sm text-amber-800 dark:text-amber-200">
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning">
Permission not granted. Click the button below to request
access to your {permissionType}.
</p>
+139 -40
View File
@@ -25,6 +25,7 @@ import {
LuLock,
LuPuzzle,
LuTrash2,
LuTriangleAlert,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -83,6 +84,7 @@ import type {
LocationItem,
ProxyCheckResult,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
VpnConfig,
} from "@/types";
@@ -204,6 +206,16 @@ type TableMeta = {
// Team locks
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
};
type SyncStatusDot = {
@@ -234,21 +246,21 @@ function getProfileSyncStatusDot(
switch (status) {
case "syncing":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Syncing...",
animate: true,
encrypted,
};
case "waiting":
return {
color: "bg-yellow-500",
tooltip: "Waiting to sync",
color: "bg-warning",
tooltip: "Close the profile to sync",
animate: false,
encrypted,
};
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: profile.last_sync
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -257,7 +269,7 @@ function getProfileSyncStatusDot(
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
encrypted,
@@ -265,7 +277,7 @@ function getProfileSyncStatusDot(
case "disabled":
if (profile.last_sync) {
return {
color: "bg-gray-400",
color: "bg-muted-foreground",
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
animate: false,
encrypted: false,
@@ -801,6 +813,14 @@ interface ProfilesDataTableProps {
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
getProfileSyncInfo?: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
}
export function ProfilesDataTable({
@@ -828,6 +848,8 @@ export function ProfilesDataTable({
onToggleProfileSync,
crossOsUnlocked = false,
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -951,8 +973,7 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed);
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return;
@@ -963,7 +984,7 @@ export function ProfilesDataTable({
} catch (e) {
console.error("Failed to load countries:", e);
}
}, [countriesLoaded, canCreateLocationProxy]);
}, [countriesLoaded]);
// Load cached check results for proxies
React.useEffect(() => {
@@ -1079,6 +1100,7 @@ export function ProfilesDataTable({
isUpdating,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
);
// Listen for sync status events
@@ -1528,6 +1550,10 @@ export function ProfilesDataTable({
isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
}),
[
t,
@@ -1577,11 +1603,12 @@ export function ProfilesDataTable({
crossOsUnlocked,
syncUnlocked,
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
getProfileSyncInfo,
onLaunchWithSync,
],
);
@@ -1806,23 +1833,81 @@ export function ProfilesDataTable({
}
};
const syncInfo = meta.getProfileSyncInfo(profile.id);
const isLeader = syncInfo?.isLeader === true;
const isFollower = syncInfo?.isLeader === false;
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
const stopTooltip = isLeader
? meta.t("profiles.synchronizer.stopLeader")
: isFollower
? meta.t("profiles.synchronizer.stopFollower", {
leaderName: syncInfo?.session.leader_profile_name ?? "",
})
: tooltipContent;
const handleStop = async () => {
if (isLeader && syncInfo) {
// Stop leader: invoke stop_sync_session which kills leader + all followers
try {
await invoke("stop_sync_session", {
sessionId: syncInfo.session.id,
});
} catch (error) {
console.error("Failed to stop sync session:", error);
}
} else if (isFollower && syncInfo) {
// Stop follower: remove from session
try {
await invoke("remove_sync_follower", {
sessionId: syncInfo.session.id,
followerProfileId: profile.id,
});
} catch (error) {
console.error("Failed to remove sync follower:", error);
}
} else {
await handleProfileStop(profile);
}
};
const buttonVariant = isRunning
? isFollower
? "secondary"
: "destructive"
: "default";
return (
<div className="flex gap-2 items-center">
{isDesynced && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("profiles.synchronizer.desyncedTooltip", {
url: syncInfo?.failedAtUrl ?? "",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<RippleButton
variant={isRunning ? "destructive" : "default"}
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
)}
onClick={() =>
isRunning
? handleProfileStop(profile)
? void handleStop()
: handleProfileLaunch(profile)
}
>
@@ -1838,8 +1923,10 @@ export function ProfilesDataTable({
</RippleButton>
</span>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
{(stopTooltip || tooltipContent) && (
<TooltipContent>
{isRunning ? stopTooltip : tooltipContent}
</TooltipContent>
)}
</Tooltip>
</div>
@@ -1930,12 +2017,13 @@ export function ProfilesDataTable({
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const lockedEmail = meta.getProfileLockEmail(profile.id);
const isLocked = meta.isProfileLockedByAnother(profile.id);
@@ -1990,12 +2078,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<TagsCell
@@ -2018,12 +2107,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<NoteCell
@@ -2044,12 +2134,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
@@ -2188,28 +2279,35 @@ export function ProfilesDataTable({
/>
None
</CommandItem>
{meta.storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id && !effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
{meta.storedProxies
.filter(
(proxy: StoredProxy) =>
!proxy.is_cloud_managed &&
!proxy.is_cloud_derived,
)
.map((proxy: StoredProxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
@@ -2519,6 +2617,7 @@ export function ProfilesDataTable({
onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
+11
View File
@@ -19,6 +19,7 @@ import {
LuSettings,
LuShieldCheck,
LuTrash2,
LuUsers,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
@@ -65,6 +66,7 @@ interface ProfileInfoDialogProps {
onOpenBypassRules?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
onOpenBypassRules,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
@@ -251,6 +254,14 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuUsers className="w-4 h-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => handleAction(() => onLaunchWithSync?.(profile)),
disabled: isDisabled || isRunning || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
icon: <LuCopy className="w-4 h-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
+28 -26
View File
@@ -186,9 +186,7 @@ export function ProxyAssignmentDialog({
const proxy = storedProxies.find(
(p) => p.id === selectedId,
);
return proxy
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
: "None";
return proxy ? proxy.name : "None";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -216,28 +214,32 @@ export function ProxyAssignmentDialog({
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</CommandItem>
))}
{storedProxies
.filter(
(proxy) =>
!proxy.is_cloud_managed && !proxy.is_cloud_derived,
)
.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
@@ -276,7 +278,7 @@ export function ProxyAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+3 -1
View File
@@ -50,7 +50,9 @@ export function ProxyCheckButton({
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.proxy_settings,
proxySettings: proxy.dynamic_proxy_url
? undefined
: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
+339 -147
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
@@ -20,10 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProxySettings, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
interface RegularFormData {
name: string;
proxy_type: string;
host: string;
@@ -32,6 +34,14 @@ interface ProxyFormData {
password: string;
}
interface DynamicFormData {
name: string;
url: string;
format: string;
}
type ProxyMode = "regular" | "dynamic";
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -43,8 +53,11 @@ export function ProxyFormDialog({
onClose,
editingProxy,
}: ProxyFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({
const [isTesting, setIsTesting] = useState(false);
const [mode, setMode] = useState<ProxyMode>("regular");
const [regularForm, setRegularForm] = useState<RegularFormData>({
name: "",
proxy_type: "http",
host: "",
@@ -52,9 +65,14 @@ export function ProxyFormDialog({
username: "",
password: "",
});
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
name: "",
url: "",
format: "json",
});
const resetForm = useCallback(() => {
setFormData({
setRegularForm({
name: "",
proxy_type: "http",
host: "",
@@ -62,62 +80,134 @@ export function ProxyFormDialog({
username: "",
password: "",
});
setDynamicForm({
name: "",
url: "",
format: "json",
});
setMode("regular");
}, []);
// Load editing proxy data when dialog opens
useEffect(() => {
if (isOpen) {
if (editingProxy) {
setFormData({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
if (editingProxy.dynamic_proxy_url) {
setMode("dynamic");
setDynamicForm({
name: editingProxy.name,
url: editingProxy.dynamic_proxy_url,
format: editingProxy.dynamic_proxy_format || "json",
});
} else {
setMode("regular");
setRegularForm({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
}
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
toast.error("Proxy name is required");
const handleTestDynamic = useCallback(async () => {
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
setIsTesting(true);
try {
const settings = await invoke<ProxySettings>("fetch_dynamic_proxy", {
url: dynamicForm.url.trim(),
format: dynamicForm.format,
});
toast.success(
t("proxies.dynamic.testSuccess", {
host: settings.host,
port: settings.port,
}),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("proxies.dynamic.testFailed", { error: errorMessage }));
} finally {
setIsTesting(false);
}
}, [dynamicForm, t]);
if (!formData.host.trim() || !formData.port) {
toast.error("Host and port are required");
return;
const handleSubmit = useCallback(async () => {
if (mode === "regular") {
if (!regularForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!regularForm.host.trim() || !regularForm.port) {
toast.error(
t("proxies.form.hostPortRequired", "Host and port are required"),
);
return;
}
} else {
if (!dynamicForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
}
setIsSubmitting(true);
try {
const proxySettings = {
proxy_type: formData.proxy_type,
host: formData.host.trim(),
port: formData.port,
username: formData.username.trim() || undefined,
password: formData.password.trim() || undefined,
};
if (editingProxy) {
// Update existing proxy
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy updated successfully");
if (mode === "dynamic") {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyUpdated"));
} else {
// Create new proxy
await invoke("create_stored_proxy", {
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy created successfully");
if (mode === "dynamic") {
await invoke("create_stored_proxy", {
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("create_stored_proxy", {
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyCreated"));
}
onClose();
@@ -129,7 +219,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onClose]);
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -137,125 +227,227 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isFormValid =
formData.name.trim() &&
formData.host.trim() &&
formData.port > 0 &&
formData.port <= 65535;
const isRegularValid =
regularForm.name.trim() &&
regularForm.host.trim() &&
regularForm.port > 0 &&
regularForm.port <= 65535;
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proxy-name">Proxy Name</Label>
<Input
id="proxy-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
{!editingProxy && (
<Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
<TabsList className="w-full">
<TabsTrigger value="regular" className="flex-1">
{t("proxies.tabs.regular")}
</TabsTrigger>
<TabsTrigger value="dynamic" className="flex-1">
{t("proxies.tabs.dynamic")}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={formData.proxy_type}
onValueChange={(value) =>
setFormData({ ...formData, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editingProxy && isEditingDynamic && (
<p className="text-xs text-muted-foreground">
{t("proxies.dynamic.description")}
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={formData.host}
onChange={(e) =>
setFormData({ ...formData, host: e.target.value })
}
placeholder="e.g. 127.0.0.1"
disabled={isSubmitting}
/>
</div>
{mode === "regular" ? (
<>
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
id="proxy-name"
value={regularForm.name}
onChange={(e) =>
setRegularForm({ ...regularForm, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({
...formData,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder="e.g. 8080"
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Select
value={regularForm.proxy_type}
onValueChange={(value) =>
setRegularForm({ ...regularForm, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={formData.username}
onChange={(e) =>
setFormData({
...formData,
username: e.target.value,
})
}
placeholder="Proxy username"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={regularForm.host}
onChange={(e) =>
setRegularForm({ ...regularForm, host: e.target.value })
}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({
...formData,
password: e.target.value,
})
}
placeholder="Proxy password"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={regularForm.port}
onChange={(e) =>
setRegularForm({
...regularForm,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{t("proxies.form.username")} (
{t("proxies.form.usernamePlaceholder")})
</Label>
<Input
id="proxy-username"
value={regularForm.username}
onChange={(e) =>
setRegularForm({
...regularForm,
username: e.target.value,
})
}
placeholder={t("proxies.form.usernamePlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")} (
{t("proxies.form.passwordPlaceholder")})
</Label>
<Input
id="proxy-password"
type="password"
value={regularForm.password}
onChange={(e) =>
setRegularForm({
...regularForm,
password: e.target.value,
})
}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
<Input
id="dynamic-name"
value={dynamicForm.name}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, name: e.target.value })
}
placeholder="e.g. My Tunnel"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
<Input
id="dynamic-url"
value={dynamicForm.url}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, url: e.target.value })
}
placeholder={t("proxies.dynamic.urlPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>{t("proxies.dynamic.format")}</Label>
<Select
value={dynamicForm.format}
onValueChange={(value) =>
setDynamicForm({ ...dynamicForm, format: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
{t("proxies.dynamic.formatJson")}
</SelectItem>
<SelectItem value="text">
{t("proxies.dynamic.formatText")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{dynamicForm.format === "json"
? t("proxies.dynamic.formatJsonHint")
: t("proxies.dynamic.formatTextHint")}
</p>
</div>
<RippleButton
variant="outline"
size="sm"
onClick={handleTestDynamic}
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
>
{isTesting
? t("proxies.dynamic.testing")
: t("proxies.dynamic.testUrl")}
</RippleButton>
</>
)}
</div>
<DialogFooter>
@@ -264,14 +456,14 @@ export function ProxyFormDialog({
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
{t("common.cancel", "Cancel")}
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={!isFormValid}
>
{editingProxy ? "Update Proxy" : "Create Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</LoadingButton>
</DialogFooter>
</DialogContent>
+4 -4
View File
@@ -429,14 +429,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-sm">Imported:</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span>
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
@@ -444,7 +444,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400">
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
@@ -459,7 +459,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-red-600 dark:text-red-400"
className="text-xs text-destructive"
>
{error}
</div>
+50 -70
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { GoGlobe, GoPlus } from "react-icons/go";
import { GoPlus } from "react-icons/go";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -40,8 +40,6 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { FlagIcon } from "./flag-icon";
import { LocationProxyDialog } from "./location-proxy-dialog";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
import { VpnCheckButton } from "./vpn-check-button";
@@ -59,10 +57,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -70,18 +68,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
@@ -98,7 +100,6 @@ export function ProxyManagementDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showLocationDialog, setShowLocationDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -138,12 +139,10 @@ export function ProxyManagementDialog({
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
// Keep cloud-derived location proxies
// Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated)
const storedProxies = rawProxies
.filter((p) => !p.is_cloud_managed)
.filter((p) => !p.is_cloud_managed && !p.is_cloud_derived)
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
// Listen for proxy sync status events
useEffect(() => {
@@ -408,17 +407,6 @@ export function ProxyManagementDialog({
</RippleButton>
</div>
<div className="flex gap-2">
{hasCloudProxy && (
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
@@ -458,34 +446,33 @@ export function ProxyManagementDialog({
proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{proxy.name}
{proxy.dynamic_proxy_url && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
Dynamic
</Badge>
)}
</div>
</TableCell>
<TableCell>
@@ -550,24 +537,22 @@ export function ProxyManagementDialog({
}));
}}
/>
{!isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -826,11 +811,6 @@ export function ProxyManagementDialog({
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
/>
<LocationProxyDialog
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
<VpnFormDialog
isOpen={showVpnForm}
onClose={handleVpnFormClose}
+2 -2
View File
@@ -170,7 +170,7 @@ export function SettingsDialog({
const getStatusBadge = useCallback((isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="text-green-800 bg-green-100">
<Badge variant="default" className="text-success-foreground bg-success">
Granted
</Badge>
);
@@ -1018,7 +1018,7 @@ export function SettingsDialog({
</div>
) : (
<div className="space-y-1">
<p className="text-sm font-medium text-orange-600">
<p className="text-sm font-medium text-warning">
Trial expired
</p>
<p className="text-xs text-muted-foreground">
+3 -3
View File
@@ -269,7 +269,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
@@ -530,13 +530,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="w-2 h-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isCrossOsProfile } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type {
BrowserProfile,
SyncSessionInfo,
WayfernFingerprintConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
function getScreenSize(
profile: BrowserProfile,
): { w: number; h: number } | null {
const fp = profile.wayfern_config?.fingerprint;
if (!fp) return null;
try {
const parsed: WayfernFingerprintConfig = JSON.parse(fp);
const w = parsed.screenWidth ?? parsed.windowInnerWidth;
const h = parsed.screenHeight ?? parsed.windowInnerHeight;
if (w && h) return { w, h };
} catch {
// ignore
}
return null;
}
interface SyncFollowerDialogProps {
isOpen: boolean;
onClose: () => void;
leaderProfile: BrowserProfile | null;
allProfiles: BrowserProfile[];
runningProfiles: Set<string>;
}
export function SyncFollowerDialog({
isOpen,
onClose,
leaderProfile,
allProfiles,
runningProfiles,
}: SyncFollowerDialogProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const eligibleProfiles = allProfiles.filter(
(p) =>
p.id !== leaderProfile?.id &&
p.browser === "wayfern" &&
!runningProfiles.has(p.id) &&
!isCrossOsProfile(p),
);
const leaderScreenSize = useMemo(
() => (leaderProfile ? getScreenSize(leaderProfile) : null),
[leaderProfile],
);
const handleToggle = useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}, []);
const handleStart = useCallback(() => {
if (!leaderProfile || selectedIds.size === 0) return;
const ids = Array.from(selectedIds);
const leaderId = leaderProfile.id;
setSelectedIds(new Set());
onClose();
invoke<SyncSessionInfo>("start_sync_session", {
leaderProfileId: leaderId,
followerProfileIds: ids,
}).catch((err) => {
console.error("Failed to start sync session:", err);
showErrorToast(err instanceof Error ? err.message : String(err));
});
}, [leaderProfile, selectedIds, onClose]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setSelectedIds(new Set());
onClose();
}
},
[onClose],
);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("profiles.synchronizer.selectFollowers")}
</DialogTitle>
<DialogDescription>
{t("profiles.synchronizer.selectFollowersDesc")}
</DialogDescription>
</DialogHeader>
{leaderProfile && (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 border border-primary/20">
<Badge variant="default" className="text-xs">
{t("profiles.synchronizer.leader")}
</Badge>
<span className="text-sm font-medium truncate">
{leaderProfile.name}
</span>
</div>
<div className="border rounded-md">
<ScrollArea className="h-[150px]">
<div className="space-y-1 p-2">
{eligibleProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("profiles.synchronizer.wayfernOnly")}
</p>
) : (
eligibleProfiles.map((profile) => {
const followerSize = getScreenSize(profile);
const isFlaky =
leaderScreenSize &&
followerSize &&
(leaderScreenSize.w !== followerSize.w ||
leaderScreenSize.h !== followerSize.h);
return (
<div
key={profile.id}
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
onClick={() =>
handleToggle(
profile.id,
!selectedIds.has(profile.id),
)
}
onKeyDown={() => {}}
role="button"
tabIndex={0}
>
<Checkbox
checked={selectedIds.has(profile.id)}
onCheckedChange={(checked) =>
handleToggle(profile.id, checked === true)
}
/>
<span className="text-sm truncate flex-1">
{profile.name}
</span>
{isFlaky && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-warning border-warning/50 shrink-0"
>
{t("profiles.synchronizer.flakyBadge")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
{t("profiles.synchronizer.flakyTooltip")}
</TooltipContent>
</Tooltip>
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
</div>
)}
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => handleOpenChange(false)}
>
{t("common.buttons.cancel")}
</RippleButton>
<RippleButton disabled={selectedIds.size === 0} onClick={handleStart}>
{t("profiles.synchronizer.startSession")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -63,4 +63,4 @@ function AlertDescription({
);
}
export { Alert, AlertTitle, AlertDescription };
export { Alert, AlertDescription, AlertTitle };
+4 -4
View File
@@ -81,10 +81,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+2 -2
View File
@@ -373,9 +373,9 @@ function getPayloadConfigFromPayload(
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
};
+3 -3
View File
@@ -167,11 +167,11 @@ function CommandShortcut({
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
};
+5 -5
View File
@@ -240,18 +240,18 @@ function DropdownMenuSubContent({
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+2 -2
View File
@@ -634,7 +634,7 @@ function HighlightItem<T extends React.ElementType>({
export {
Highlight,
HighlightItem,
useHighlight,
type HighlightProps,
type HighlightItemProps,
type HighlightProps,
useHighlight,
};
+1 -1
View File
@@ -45,4 +45,4 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
+3 -3
View File
@@ -100,11 +100,11 @@ function TableCaption({
export {
Table,
TableHeader,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableCell,
TableCaption,
};
+8 -8
View File
@@ -200,17 +200,17 @@ function TabsContents(props: TabsContentsProps) {
export {
Tabs,
TabsContent,
type TabsContentProps,
TabsContents,
type TabsContentsProps,
TabsHighlight,
TabsHighlightItem,
TabsList,
TabsTrigger,
TabsContent,
TabsContents,
type TabsProps,
type TabsHighlightProps,
type TabsHighlightItemProps,
type TabsHighlightProps,
TabsList,
type TabsListProps,
type TabsProps,
TabsTrigger,
type TabsTriggerProps,
type TabsContentProps,
type TabsContentsProps,
};
+1 -1
View File
@@ -70,4 +70,4 @@ function TooltipContent({
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+1 -1
View File
@@ -75,7 +75,7 @@ export function VpnCheckButton({
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="w-3 h-3 text-green-500" />
<FiCheck className="w-3 h-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
+5 -5
View File
@@ -295,13 +295,13 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
<LuShield className="w-8 h-8 text-success" />
<div>
<div className="font-medium text-green-600 dark:text-green-400">
<div className="font-medium text-success">
VPN Imported Successfully
</div>
<div className="text-sm text-muted-foreground">
@@ -311,10 +311,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-red-600 dark:text-red-400">
<div className="font-medium text-destructive">
Import Failed
</div>
<div className="text-sm text-red-600 dark:text-red-400">
<div className="text-sm text-destructive">
{vpnImportResult.error}
</div>
</div>
+17
View File
@@ -335,6 +335,23 @@ export function useBrowserDownload() {
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
} else if (progress.stage === "error") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
next.delete(progress.browser);
return next;
});
dismissToast(
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
showErrorToast(
`${browserName} ${progress.version}: extraction failed`,
{
description:
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
},
);
} else if (progress.stage === "completed") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
+8 -3
View File
@@ -15,6 +15,7 @@ export function useBrowserState(
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
crossOsUnlocked = false,
) {
const [isClient, setIsClient] = useState(false);
@@ -52,7 +53,7 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile)) return false;
if (isCrossOsProfile(profile) && !crossOsUnlocked) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
@@ -80,6 +81,7 @@ export function useBrowserState(
isAnyInstanceRunning,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
@@ -157,8 +159,10 @@ export function useBrowserState(
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName} and is not supported on this system`;
if (!crossOsUnlocked) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`;
}
}
const isRunning = runningProfiles.has(profile.id);
@@ -193,6 +197,7 @@ export function useBrowserState(
canLaunchProfile,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
+89
View File
@@ -0,0 +1,89 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { SyncSessionInfo } from "@/types";
/**
* Hook to track active synchronizer sessions and provide helper methods
* for determining if a profile is a leader, follower, or desynced.
*/
export function useSyncSessions() {
const [sessions, setSessions] = useState<SyncSessionInfo[]>([]);
const loadSessions = useCallback(async () => {
try {
const data = await invoke<SyncSessionInfo[]>("get_sync_sessions");
setSessions(data);
} catch (err) {
console.error("Failed to load sync sessions:", err);
}
}, []);
useEffect(() => {
let changedUnlisten: (() => void) | undefined;
let endedUnlisten: (() => void) | undefined;
const setup = async () => {
await loadSessions();
changedUnlisten = await listen<SyncSessionInfo>(
"sync-session-changed",
(event) => {
setSessions((prev) => {
const idx = prev.findIndex((s) => s.id === event.payload.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = event.payload;
return next;
}
return [...prev, event.payload];
});
},
);
endedUnlisten = await listen<string>("sync-session-ended", (event) => {
setSessions((prev) => prev.filter((s) => s.id !== event.payload));
});
};
void setup();
return () => {
changedUnlisten?.();
endedUnlisten?.();
};
}, [loadSessions]);
/** Find the session a profile belongs to and its role */
const getProfileSyncInfo = useCallback(
(
profileId: string,
):
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined => {
for (const session of sessions) {
if (session.leader_profile_id === profileId) {
return { session, isLeader: true, failedAtUrl: null };
}
const follower = session.followers.find(
(f) => f.profile_id === profileId,
);
if (follower) {
return {
session,
isLeader: false,
failedAtUrl: follower.failed_at_url,
};
}
}
return undefined;
},
[sessions],
);
return { sessions, getProfileSyncInfo, loadSessions };
}
-42
View File
@@ -3,11 +3,9 @@ import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showAutoUpdateToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
interface VersionUpdateProgress {
@@ -76,53 +74,13 @@ export function useVersionUpdater() {
if (progress.status === "updating") {
setIsUpdating(true);
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast("Checking for browser updates...", {
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
onCancel: () => dismissToast("unified-version-update"),
});
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
// Refresh status
void loadUpdateStatus();
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
+38 -6
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
"stopLeader": "Stop this profile and all its followers",
"stopFollower": "Following actions of {{leaderName}}",
"desyncedTooltip": "Synchronization failed at {{url}}",
"paidFeature": "Synchronizer is a paid feature",
"wayfernOnly": "Only Wayfern profiles can be synchronized",
"selectFollowers": "Select Follower Profiles",
"selectFollowersDesc": "Choose profiles that will mirror the actions of the leader profile. Only stopped Wayfern profiles can be selected.",
"leader": "Leader",
"follower": "Follower",
"startSession": "Start Sync Session",
"noFollowers": "Select at least one follower profile",
"flakyBadge": "FLAKY",
"flakyTooltip": "This profile has a different screen resolution than the leader. Page layouts may differ, causing clicks and interactions to hit the wrong elements."
},
"ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Regular",
"dynamic": "Dynamic"
},
"dynamic": {
"description": "Dynamic proxy fetches connection details from a URL each time a profile is launched.",
"url": "Proxy URL",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "Dynamic proxy URL is required",
"format": "Response Format",
"formatJson": "JSON",
"formatText": "Text",
"formatJsonHint": "Expects JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port",
"testUrl": "Test URL",
"testing": "Testing...",
"testSuccess": "Proxy working: {{host}}:{{port}}",
"testFailed": "Proxy test failed: {{error}}",
"fetchFailed": "Failed to fetch dynamic proxy: {{error}}"
},
"check": {
"checking": "Checking proxy...",
"valid": "Proxy is valid",
@@ -384,7 +420,8 @@
"token": "MCP Token",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
}
},
"mcpCopyHint": "Add this to your MCP client config to connect."
},
"import": {
"title": "Import Profile",
@@ -541,11 +578,6 @@
"unknownError": "An unknown error occurred. Please try again."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},

Some files were not shown because too many files have changed in this diff Show More