Compare commits

..

74 Commits

Author SHA1 Message Date
zhom 08453fe9a6 build: update codeql permissions 2025-06-17 07:05:20 +04:00
zhom b486f00875 chore: version bump 2025-06-17 07:00:27 +04:00
zhom 703154b30f chore: linting 2025-06-17 07:00:18 +04:00
zhom 130f8b86d1 refactor: browser auto-update 2025-06-17 06:55:52 +04:00
zhom 607ed66e29 style: copy 2025-06-17 06:32:47 +04:00
zhom 9570b6d605 build: fix codeql permissions 2025-06-17 06:28:34 +04:00
zhom 2d92cbb0e5 refactor: fetch release information the same way for manual and automatic checks 2025-06-17 06:17:57 +04:00
zhom 251016609f refactor: change event emitting and remove sleep 2025-06-17 04:05:17 +04:00
zhom bddf796946 refactor: don't mark updates as automatic and fetch versions via version_updater only 2025-06-17 03:44:59 +04:00
zhom 8d793a6868 style: make release type selector behave the same in both creation and change modals 2025-06-17 03:42:47 +04:00
zhom 469f161293 refactor: add extra settings to not show update prompt 2025-06-17 03:24:10 +04:00
zhom 9756e64319 refactor: increase default update interval for firefox 2025-06-17 03:22:30 +04:00
zhom 800544ede9 refactor: supress all update prompts in the ui for firefox 2025-06-17 03:21:21 +04:00
zhom aa2228a8aa fix: show switch release option for correct browsers 2025-06-17 03:10:16 +04:00
zhom 432e5bff90 refactor: switch download mode from 0 to 2 on firefox 2025-06-17 03:06:50 +04:00
zhom f4b60eb6c7 chore: linting 2025-06-17 03:04:06 +04:00
zhom 30122c5781 reafctor: get cache releases first and don't return twilight in results 2025-06-17 03:00:09 +04:00
zhom b71d84fda4 refactor: improve signatures for public functions 2025-06-17 02:58:46 +04:00
zhom 859af72724 refactor: improve auto-update inside browser prevention 2025-06-17 02:57:30 +04:00
zhom 0360a89ceb style: remove 'enable automatic browser updates' setting 2025-06-17 02:46:33 +04:00
zhom cb6f744d6b style: only show the select menu if both stable and nightly releases are available 2025-06-17 02:42:03 +04:00
zhom 575d7f80b1 style: remove switch release option for zen and chromium 2025-06-17 02:40:48 +04:00
zhom d05b69ff3d build: require codeql and spellcheck to pass successfully before build starts 2025-06-16 23:28:53 +04:00
zhom 54abb11129 build: run spellcheck on build 2025-06-16 03:41:38 +04:00
zhom 04c690c750 build: run codeql before build 2025-06-16 03:30:13 +04:00
zhom 9a4be86e95 style: copy 2025-06-15 05:40:18 +04:00
zhom 6d013d86aa Merge pull request #26 from zhom/dependabot/cargo/src-tauri/rust-dependencies-f6a8cef228
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 2 updates
2025-06-14 18:26:30 +00:00
zhom 769fbf9d75 Merge pull request #25 from zhom/dependabot/github_actions/github-actions-eba975d771
ci(deps): bump stefanzweifel/git-auto-commit-action from 4 to 6 in the github-actions group
2025-06-14 18:26:09 +00:00
zhom 6e62abc601 chore: version bump 2025-06-14 22:22:17 +04:00
dependabot[bot] 8848fa8130 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 2 updates: [serde_with](https://github.com/jonasbb/serde_with) and [serde_with_macros](https://github.com/jonasbb/serde_with).


Updates `serde_with` from 3.12.0 to 3.13.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.12.0...v3.13.0)

Updates `serde_with_macros` from 3.12.0 to 3.13.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.12.0...v3.13.0)

---
updated-dependencies:
- dependency-name: serde_with
  dependency-version: 3.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 18:21:50 +00:00
zhom 1f0ecbe36e docs: use dark-themed preview for users with dark mode on 2025-06-14 22:21:26 +04:00
dependabot[bot] f83f2033fe ci(deps): bump stefanzweifel/git-auto-commit-action
Bumps the github-actions group with 1 update: [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action).


Updates `stefanzweifel/git-auto-commit-action` from 4 to 6
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v4...v6)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 18:02:10 +00:00
zhom 821cd4ea82 style: only allow user to switch between releases 2025-06-14 21:55:18 +04:00
zhom d3a63c37bf test: update mock 2025-06-14 19:20:51 +04:00
zhom 95cd2426c3 feat: automatically update browsers on new versions 2025-06-14 19:03:02 +04:00
zhom 5a3fb7b2b0 refactor: install chromium update after 50 new builds 2025-06-14 18:44:03 +04:00
zhom 767a0701ce refactor: don't check for nodecar dependency updates 2025-06-14 18:33:33 +04:00
zhom ec61d51c07 build: update changelog generation workflow 2025-06-14 17:33:38 +04:00
zhom 545c518a55 feat: don't spam update notification, show more concise toasts, don't fetch unsupported browser updates 2025-06-14 16:58:55 +04:00
zhom c99eee2c21 style: use proper zen icon 2025-06-14 16:08:26 +04:00
zhom 7f3683cc2e style: show 'not supported' for tor browser proxies 2025-06-14 15:57:33 +04:00
zhom baac3a533a refactor: fetch 100 latest app updates from github releases 2025-06-14 15:43:50 +04:00
zhom 5cd1774ffc chore: remove dependabot automerge workflow 2025-06-14 15:42:02 +04:00
zhom cb87641890 Merge pull request #23 from zhom/dependabot/cargo/src-tauri/rust-dependencies-af3af11ff5
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 17 updates
2025-06-14 11:34:34 +00:00
zhom 3df5ac671b Merge pull request #24 from zhom/dependabot/npm_and_yarn/frontend-dependencies-f9895d0c1f
deps(deps): bump the frontend-dependencies group with 3 updates
2025-06-14 11:34:05 +00:00
dependabot[bot] 390f79f97b deps(deps): bump the frontend-dependencies group with 3 updates
Bumps the frontend-dependencies group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [lint-staged](https://github.com/lint-staged/lint-staged) and [undici-types](https://github.com/nodejs/undici).


Updates `@types/node` from 22.15.31 to 24.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `lint-staged` from 16.1.0 to 16.1.1
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.1.0...v16.1.1)

Updates `undici-types` from 6.21.0 to 7.8.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.0...v7.8.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: undici-types
  dependency-version: 7.8.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 10:18:35 +00:00
dependabot[bot] c4dc2ed50c deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 17 updates:

| Package | From | To |
| --- | --- | --- |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.19` | `0.12.20` |
| [windows](https://github.com/microsoft/windows-rs) | `0.61.1` | `0.61.3` |
| [adler2](https://github.com/oyvindln/adler2) | `2.0.0` | `2.0.1` |
| [bytemuck](https://github.com/Lokathor/bytemuck) | `1.23.0` | `1.23.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.26` | `1.2.27` |
| [cfg-if](https://github.com/rust-lang/cfg-if) | `1.0.0` | `1.0.1` |
| [enumflags2](https://github.com/meithecatte/enumflags2) | `0.7.11` | `0.7.12` |
| [enumflags2_derive](https://github.com/meithecatte/enumflags2) | `0.7.11` | `0.7.12` |
| [hermit-abi](https://github.com/hermit-os/hermit-rs) | `0.5.1` | `0.5.2` |
| [libc](https://github.com/rust-lang/libc) | `0.2.172` | `0.2.173` |
| [memchr](https://github.com/BurntSushi/memchr) | `2.7.4` | `2.7.5` |
| [miniz_oxide](https://github.com/Frommi/miniz_oxide) | `0.8.8` | `0.8.9` |
| [plist](https://github.com/ebarnard/rust-plist) | `1.7.1` | `1.7.2` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.32.0` | `0.37.5` |
| redox_syscall | `0.5.12` | `0.5.13` |
| [rustc-demangle](https://github.com/rust-lang/rustc-demangle) | `0.1.24` | `0.1.25` |
| [windows-link](https://github.com/microsoft/windows-rs) | `0.1.1` | `0.1.3` |


Updates `reqwest` from 0.12.19 to 0.12.20
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.19...v0.12.20)

Updates `windows` from 0.61.1 to 0.61.3
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

Updates `adler2` from 2.0.0 to 2.0.1
- [Changelog](https://github.com/oyvindln/adler2/blob/main/CHANGELOG.md)
- [Commits](https://github.com/oyvindln/adler2/commits)

Updates `bytemuck` from 1.23.0 to 1.23.1
- [Changelog](https://github.com/Lokathor/bytemuck/blob/main/changelog.md)
- [Commits](https://github.com/Lokathor/bytemuck/compare/v1.23.0...v1.23.1)

Updates `cc` from 1.2.26 to 1.2.27
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.26...cc-v1.2.27)

Updates `cfg-if` from 1.0.0 to 1.0.1
- [Release notes](https://github.com/rust-lang/cfg-if/releases)
- [Changelog](https://github.com/rust-lang/cfg-if/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cfg-if/compare/1.0.0...v1.0.1)

Updates `enumflags2` from 0.7.11 to 0.7.12
- [Release notes](https://github.com/meithecatte/enumflags2/releases)
- [Commits](https://github.com/meithecatte/enumflags2/compare/v0.7.11...v0.7.12)

Updates `enumflags2_derive` from 0.7.11 to 0.7.12
- [Release notes](https://github.com/meithecatte/enumflags2/releases)
- [Commits](https://github.com/meithecatte/enumflags2/compare/v0.7.11...v0.7.12)

Updates `hermit-abi` from 0.5.1 to 0.5.2
- [Release notes](https://github.com/hermit-os/hermit-rs/releases)
- [Commits](https://github.com/hermit-os/hermit-rs/compare/hermit-abi-0.5.1...hermit-abi-0.5.2)

Updates `libc` from 0.2.172 to 0.2.173
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.173/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.172...0.2.173)

Updates `memchr` from 2.7.4 to 2.7.5
- [Commits](https://github.com/BurntSushi/memchr/compare/2.7.4...2.7.5)

Updates `miniz_oxide` from 0.8.8 to 0.8.9
- [Changelog](https://github.com/Frommi/miniz_oxide/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Frommi/miniz_oxide/commits)

Updates `plist` from 1.7.1 to 1.7.2
- [Release notes](https://github.com/ebarnard/rust-plist/releases)
- [Commits](https://github.com/ebarnard/rust-plist/compare/v1.7.1...v1.7.2)

Updates `quick-xml` from 0.32.0 to 0.37.5
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.32.0...v0.37.5)

Updates `redox_syscall` from 0.5.12 to 0.5.13

Updates `rustc-demangle` from 0.1.24 to 0.1.25
- [Commits](https://github.com/rust-lang/rustc-demangle/commits)

Updates `windows-link` from 0.1.1 to 0.1.3
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows
  dependency-version: 0.61.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: adler2
  dependency-version: 2.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bytemuck
  dependency-version: 1.23.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.27
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cfg-if
  dependency-version: 1.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: enumflags2
  dependency-version: 0.7.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: enumflags2_derive
  dependency-version: 0.7.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hermit-abi
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.173
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: memchr
  dependency-version: 2.7.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: miniz_oxide
  dependency-version: 0.8.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: plist
  dependency-version: 1.7.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.37.5
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: redox_syscall
  dependency-version: 0.5.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustc-demangle
  dependency-version: 0.1.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows-link
  dependency-version: 0.1.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 09:44:26 +00:00
zhom 3b7315cc0d docs: add fossa badge 2025-06-14 03:42:52 +04:00
zhom bbd0f5df0c chore: version bump 2025-06-14 02:51:24 +04:00
zhom 8e7982bdf8 fix: properly handle urls added by system events 2025-06-14 02:11:40 +04:00
zhom 9ac662aee8 chore: use single-instance plugin as described by the docs 2025-06-13 21:40:50 +04:00
zhom 2394716ea3 chore: version bump 2025-06-11 04:43:18 +04:00
zhom 06992f8b9a build: use nightly-date-hash naming for rolling releases 2025-06-11 04:23:03 +04:00
zhom 5a454e3647 chore: linting 2025-06-11 04:17:07 +04:00
zhom 3f91d92d8b refactor: add missing linting dependency 2025-06-11 04:00:12 +04:00
zhom c586046542 refactor: codacy autofix 2025-06-11 03:56:14 +04:00
zhom 42f63172fb docs: add codacy badge 2025-06-11 03:55:36 +04:00
zhom f717600fcb style: trucate profile name in table after over 15 characters 2025-06-11 03:40:01 +04:00
zhom c807ea5596 build: add changelog generation on release 2025-06-11 03:23:16 +04:00
zhom df2c1316d4 build: disable windows build for regular releases 2025-06-11 03:04:34 +04:00
zhom 1fdc552dc7 fix: make sure proxy configuration is discovered properly in the production build 2025-06-11 03:02:40 +04:00
zhom ab563f81fa style: update proxy settings form to match proxy-settings-dialog 2025-06-11 02:40:03 +04:00
zhom d17545bd05 docs: add extra check for agents to run tests and linting before finishing 2025-06-11 02:33:23 +04:00
zhom 29c329b432 feat: update proxy ui to accept credential outside url too 2025-06-11 01:55:54 +04:00
zhom 4d7bbe719f chore: run biome on nodecar file changes 2025-06-11 01:52:07 +04:00
zhom 5b869a6115 test: add tests for proxy manager 2025-06-10 00:33:58 +04:00
zhom c4b1745a0f style: update ui to accept proxy separately 2025-06-09 23:19:34 +04:00
zhom ac293f6204 build: rename nightly release tags for better ordering 2025-06-09 23:18:55 +04:00
zhom 7f3a3287d6 build: switch win x64 build to windows-latest 2025-06-09 19:48:30 +04:00
zhom dd9347d429 build: set proper shell for the hash step 2025-06-09 19:19:57 +04:00
zhom 615d7b8c8a build: fix windows build warnings 2025-06-09 18:56:38 +04:00
zhom 5c26ab5c33 build: windows build invalid lifetime fix 2025-06-09 18:02:48 +04:00
zhom dccaf6c7de build: blind windows ci build fixes 2025-06-09 15:20:06 +04:00
zhom bf5b2886f5 build: blind windows ci build fixes 2025-06-09 15:01:10 +04:00
67 changed files with 3687 additions and 2342 deletions
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
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.
-17
View File
@@ -20,23 +20,6 @@ updates:
prefix: "deps"
include: "scope"
# Nodecar dependencies
- package-ecosystem: "npm"
directory: "/nodecar"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
nodecar-dependencies:
patterns:
- "*"
commit-message:
prefix: "deps(nodecar)"
include: "scope"
# Rust dependencies
- package-ecosystem: "cargo"
directory: "/src-tauri"
+61
View File
@@ -0,0 +1,61 @@
name: "CodeQL"
on:
workflow_call:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "16 13 * * 5"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up pnpm package manager
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install dependencies from lockfile
run: pnpm install --frozen-lockfile
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
queries: security-extended
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
shell: bash
run: |
pnpm run build
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
@@ -1,60 +0,0 @@
name: Dependabot Automerge
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: write
checks: read
jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.actor == 'dependabot[bot]' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
contents: read
actions: read
lint-js:
name: Lint JavaScript/TypeScript
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-js.yml
secrets: inherit
lint-rust:
name: Lint Rust
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
dependabot-automerge:
name: Dependabot Automerge
if: ${{ github.actor == 'dependabot[bot]' }}
needs: [security-scan, lint-js, lint-rust]
runs-on: ubuntu-latest
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge minor and patch updates
uses: ridedott/merge-me-action@v2
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
PRESET: DEPENDABOT_MINOR
MERGE_METHOD: SQUASH
timeout-minutes: 10
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Set up pnpm package manager
uses: pnpm/action-setup@v4
- name: Set up Node.js v22
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
+35 -13
View File
@@ -37,8 +37,23 @@ jobs:
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
packages: read
actions: read
spellcheck:
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
release:
needs: [security-scan, lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -69,18 +84,18 @@ jobs:
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
- platform: "windows-11"
args: "--target x86_64-pc-windows-msvc"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
pkg_target: "latest-win-x64"
nodecar_script: "build:win-x64"
- platform: "windows-11-arm"
args: "--target aarch64-pc-windows-msvc"
arch: "aarch64"
target: "aarch64-pc-windows-msvc"
pkg_target: "latest-win-arm64"
nodecar_script: "build:win-arm64"
# - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64"
# target: "x86_64-pc-windows-msvc"
# pkg_target: "latest-win-x64"
# nodecar_script: "build:win-x64"
# - platform: "windows-11-arm"
# args: "--target aarch64-pc-windows-msvc"
# arch: "aarch64"
# target: "aarch64-pc-windows-msvc"
# pkg_target: "latest-win-arm64"
# nodecar_script: "build:win-arm64"
runs-on: ${{ matrix.platform }}
steps:
@@ -149,3 +164,10 @@ jobs:
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
- name: Commit CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@v6
with:
branch: main
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
file_pattern: CHANGELOG.md
+30 -10
View File
@@ -36,8 +36,23 @@ jobs:
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
packages: read
actions: read
spellcheck:
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
rolling-release:
needs: [security-scan, lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -68,7 +83,7 @@ jobs:
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
- platform: "windows-11"
- platform: "windows-latest"
args: "--target x86_64-pc-windows-msvc"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
@@ -136,21 +151,26 @@ jobs:
- name: Build frontend
run: pnpm build
- name: Get commit hash
id: commit
run: echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Generate nightly timestamp
id: timestamp
shell: bash
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d")
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_SHA: ${{ github.sha }}
with:
tagName: "nightly-${{ steps.commit.outputs.hash }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
+26
View File
@@ -0,0 +1,26 @@
name: Spell Check
permissions:
contents: read
on:
workflow_call:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
jobs:
spelling:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@v4
- name: Spell Check Repo
uses: crate-ci/typos@v1.33.1
+10
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"ahooks",
"appimage",
"appindicator",
"applescript",
@@ -9,9 +10,12 @@
"cdylib",
"CFURL",
"checkin",
"CLICOLOR",
"clippy",
"cmdk",
"codegen",
"devedition",
"doesn",
"donutbrowser",
"dpkg",
"dtolnay",
@@ -38,9 +42,11 @@
"msvc",
"msys",
"Mullvad",
"mullvadbrowser",
"nodecar",
"ntlm",
"objc",
"orhun",
"osascript",
"pixbuf",
"plasmohq",
@@ -56,16 +62,20 @@
"sonner",
"sspi",
"staticlib",
"stefanzweifel",
"subdirs",
"swatinem",
"sysinfo",
"systempreferences",
"taskkill",
"tauri",
"titlebar",
"Torbrowser",
"turbopack",
"unlisten",
"unminimize",
"unrs",
"urlencoding",
"vercel",
"winreg",
"wiremock",
+1
View File
@@ -3,3 +3,4 @@
- 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.
+1 -1
View File
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
## Enforcement
Violations of the Code of Conduct may be reported by pinging @zhom on Github. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+11 -1
View File
@@ -14,6 +14,12 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
@@ -23,7 +29,11 @@
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
![Donut Browser Preview](assets/preview.png)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
<img alt="Preview" src="assets/preview.png" />
</picture>
## Features
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 540 KiB

+1 -42
View File
@@ -68,48 +68,7 @@ const eslintConfig = tseslint.config(
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
"@typescript-eslint/adjacent-overload-signatures": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-exports": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/default-param-last": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-dupe-class-members": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-non-null-assertion": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/no-loss-of-precision": "off",
"@typescript-eslint/no-misused-new": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-redeclare": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-restricted-imports": "off",
"@typescript-eslint/no-restricted-types": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-useless-constructor": "off",
"@typescript-eslint/no-useless-empty-export": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/prefer-enum-initializers": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/prefer-function-type": "off",
"@typescript-eslint/prefer-literal-enum-member": "off",
"@typescript-eslint/prefer-namespace-keyword": "off",
"@typescript-eslint/prefer-optional-chain": "off",
"@typescript-eslint/require-await": "off",
// Custom rules
"@typescript-eslint/restrict-template-expressions": [
@@ -127,7 +86,7 @@ const eslintConfig = tseslint.config(
tsconfigRootDir: import.meta.dirname,
},
},
},
}
);
export default eslintConfig;
+5 -2
View File
@@ -21,5 +21,8 @@ if [ -z "$TARGET_TRIPLE" ]; then
exit 1
fi
# Copy the file
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Copy the file with target triple suffix
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Also copy a generic version for Tauri to find
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
-41
View File
@@ -66,48 +66,7 @@ const eslintConfig = tseslint.config(
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
"@typescript-eslint/adjacent-overload-signatures": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-exports": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/default-param-last": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-dupe-class-members": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-non-null-assertion": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/no-loss-of-precision": "off",
"@typescript-eslint/no-misused-new": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-redeclare": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-restricted-imports": "off",
"@typescript-eslint/no-restricted-types": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-useless-constructor": "off",
"@typescript-eslint/no-useless-empty-export": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/prefer-enum-initializers": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/prefer-function-type": "off",
"@typescript-eslint/prefer-literal-enum-member": "off",
"@typescript-eslint/prefer-namespace-keyword": "off",
"@typescript-eslint/prefer-optional-chain": "off",
"@typescript-eslint/require-await": "off",
// Custom rules
"@typescript-eslint/restrict-template-expressions": [
+6 -4
View File
@@ -2,11 +2,12 @@
"name": "nodecar",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"main": "dist/index.js",
"scripts": {
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
"dev": "node --loader ts-node/esm ./src/index.ts",
"start": "node --loader ts-node/esm ./src/index.ts",
"start": "tsc && node ./dist/index.js",
"test": "tsc && node ./dist/test-proxy.js",
"rename-binary": "sh ./copy-binary.sh",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
@@ -20,7 +21,7 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^22.15.30",
"@types/node": "^24.0.1",
"@yao-pkg/pkg": "^6.5.1",
"commander": "^14.0.0",
"dotenv": "^16.5.0",
@@ -28,6 +29,7 @@
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.9",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.0"
}
}
+71 -23
View File
@@ -1,8 +1,8 @@
import { program } from "commander";
import {
startProxyProcess,
stopProxyProcess,
stopAllProxyProcesses,
stopProxyProcess,
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
import { runProxyWorker } from "./proxy-worker";
@@ -11,79 +11,127 @@ import { runProxyWorker } from "./proxy-worker";
program
.command("proxy")
.argument("<action>", "start, stop, or list proxies")
.option(
"-u, --upstream <url>",
"upstream proxy URL (protocol://[username:password@]host:port)"
)
.option("--host <host>", "upstream proxy host")
.option("--proxy-port <port>", "upstream proxy port", Number.parseInt)
.option("--type <type>", "proxy type (http, https, socks4, socks5)")
.option("--username <username>", "proxy username")
.option("--password <password>", "proxy password")
.option(
"-p, --port <number>",
"local port to use (random if not specified)",
Number.parseInt
Number.parseInt,
)
.option("--ignore-certificate", "ignore certificate errors for HTTPS proxies")
.option("--id <id>", "proxy ID for stop command")
.option(
"-u, --upstream <url>",
"upstream proxy URL (protocol://[username:password@]host:port)",
)
.description("manage proxy servers")
.action(
async (
action: string,
options: {
upstream?: string;
host?: string;
proxyPort?: number;
type?: string;
username?: string;
password?: string;
port?: number;
ignoreCertificate?: boolean;
id?: string;
}
upstream?: string;
},
) => {
if (action === "start") {
if (!options.upstream) {
console.error("Error: Upstream proxy URL is required");
console.log(
"Example: proxy start -u http://username:password@proxy.example.com:8080"
let upstreamUrl: string;
// Build upstream URL from individual components if provided
if (options.host && options.proxyPort && options.type) {
const protocol =
options.type === "socks4" || options.type === "socks5"
? options.type
: "http";
const auth =
options.username && options.password
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
options.password,
)}@`
: "";
upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`;
} else if (options.upstream) {
upstreamUrl = options.upstream;
} else {
console.error(
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
);
console.log(
"Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass",
);
process.exit(1);
return;
}
try {
const config = await startProxyProcess(options.upstream, {
const config = await startProxyProcess(upstreamUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreCertificate,
});
console.log(JSON.stringify(config));
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
localPort: config.localPort,
localUrl: config.localUrl,
upstreamUrl: config.upstreamUrl,
}),
);
// Exit successfully to allow the process to detach
process.exit(0);
} catch (error: unknown) {
console.error(`Failed to start proxy: ${JSON.stringify(error)}`);
console.error(
`Failed to start proxy: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else if (action === "stop") {
if (options.id) {
const stopped = await stopProxyProcess(options.id);
console.log(`{
"success": ${stopped}}`);
console.log(JSON.stringify({ success: stopped }));
} else if (options.upstream) {
// Find proxies with this upstream URL
const configs = listProxyConfigs().filter(
(config) => config.upstreamUrl === options.upstream
(config) => config.upstreamUrl === options.upstream,
);
if (configs.length === 0) {
console.error(`No proxies found for ${options.upstream}`);
process.exit(1);
return;
}
for (const config of configs) {
const stopped = await stopProxyProcess(config.id);
console.log(`{
"success": ${stopped}}`);
console.log(JSON.stringify({ success: stopped }));
}
} else {
await stopAllProxyProcesses();
console.log(`{
"success": true}`);
console.log(JSON.stringify({ success: true }));
}
process.exit(0);
} else if (action === "list") {
const configs = listProxyConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else {
console.error("Invalid action. Use 'start', 'stop', or 'list'");
process.exit(1);
}
}
},
);
// Command for proxy worker (internal use)
+38 -26
View File
@@ -3,12 +3,12 @@ import path from "node:path";
import getPort from "get-port";
import {
type ProxyConfig,
saveProxyConfig,
getProxyConfig,
deleteProxyConfig,
isProcessRunning,
generateProxyId,
getProxyConfig,
isProcessRunning,
listProxyConfigs,
saveProxyConfig,
} from "./proxy-storage";
/**
@@ -19,50 +19,53 @@ import {
*/
export async function startProxyProcess(
upstreamUrl: string,
options: { port?: number; ignoreProxyCertificate?: boolean } = {}
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
): Promise<ProxyConfig> {
// Generate a unique ID for this proxy
const id = generateProxyId();
// Get a random available port if not specified
const port = options.port || (await getPort());
const port = options.port ?? (await getPort());
// Create the proxy configuration
const config: ProxyConfig = {
id,
upstreamUrl,
localPort: port,
ignoreProxyCertificate: options.ignoreProxyCertificate || false,
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
};
// Save the configuration before starting the process
saveProxyConfig(config);
// Build the command arguments
const args = ["proxy-worker", "start", "--id", id];
const args = [
path.join(__dirname, "index.js"),
"proxy-worker",
"start",
"--id",
id,
];
// Spawn the process
const child = spawn(
process.execPath,
[path.join(__dirname, "index.js"), ...args],
{
detached: true,
stdio: "ignore",
}
);
// Spawn the process with proper detachment
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio
cwd: process.cwd(),
});
// Unref the child to allow the parent to exit independently
child.unref();
// Store the process ID
// Store the process ID and local URL
config.pid = child.pid;
config.localUrl = `http://localhost:${port}`;
config.localUrl = `http://127.0.0.1:${port}`;
// Update the configuration with the process ID
saveProxyConfig(config);
// Wait a bit to ensure the proxy has started
await new Promise((resolve) => setTimeout(resolve, 500));
// Give the worker a moment to start before returning
await new Promise((resolve) => setTimeout(resolve, 100));
return config;
}
@@ -75,7 +78,9 @@ export async function startProxyProcess(
export async function stopProxyProcess(id: string): Promise<boolean> {
const config = getProxyConfig(id);
if (!config || !config.pid) {
if (!config?.pid) {
// Try to delete the config anyway in case it exists without a PID
deleteProxyConfig(id);
return false;
}
@@ -83,10 +88,16 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
// Check if the process is running
if (isProcessRunning(config.pid)) {
// Send SIGTERM to the process
process.kill(config.pid);
process.kill(config.pid, "SIGTERM");
// Wait a bit to ensure the process has terminated
await new Promise((resolve) => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 500));
// If still running, send SIGKILL
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Delete the configuration
@@ -95,6 +106,8 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
return true;
} catch (error) {
console.error(`Error stopping proxy ${id}:`, error);
// Delete the configuration even if stopping failed
deleteProxyConfig(id);
return false;
}
}
@@ -106,7 +119,6 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
export async function stopAllProxyProcesses(): Promise<void> {
const configs = listProxyConfigs();
for (const config of configs) {
await stopProxyProcess(config.id);
}
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
await Promise.all(stopPromises);
}
+41 -17
View File
@@ -1,5 +1,5 @@
import { Server } from "proxy-chain";
import { getProxyConfig } from "./proxy-storage";
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
/**
* Run a proxy server as a worker process
@@ -8,44 +8,68 @@ import { getProxyConfig } from "./proxy-storage";
export async function runProxyWorker(id: string): Promise<void> {
// Get the proxy configuration
const config = getProxyConfig(id);
if (!config) {
console.error(`Proxy configuration ${id} not found`);
process.exit(1);
}
// Create a new proxy server
const server = new Server({
port: config.localPort,
host: "localhost",
host: "127.0.0.1",
prepareRequestFunction: () => {
return {
upstreamProxyUrl: config.upstreamUrl,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate || false,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
};
},
});
// Handle process termination
process.on("SIGTERM", async () => {
console.log(`Proxy worker ${id} received SIGTERM, shutting down...`);
await server.close(true);
// Handle process termination gracefully
const gracefulShutdown = async (signal: string) => {
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
try {
await server.close(true);
console.log(`Proxy worker ${id} shut down successfully`);
} catch (error) {
console.error(`Error during shutdown for proxy ${id}:`, error);
}
process.exit(0);
};
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
console.error(`Uncaught exception in proxy worker ${id}:`, error);
process.exit(1);
});
process.on("SIGINT", async () => {
console.log(`Proxy worker ${id} received SIGINT, shutting down...`);
await server.close(true);
process.exit(0);
process.on("unhandledRejection", (reason) => {
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
process.exit(1);
});
// Start the server
try {
await server.listen();
// Update the config with the actual port (in case it was auto-assigned)
config.localPort = server.port;
config.localUrl = `http://127.0.0.1:${server.port}`;
updateProxyConfig(config);
console.log(`Proxy worker ${id} started on port ${server.port}`);
console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`);
// Keep the process alive
setInterval(() => {
// Do nothing, just keep the process alive
}, 60000);
} catch (error) {
console.error(`Failed to start proxy worker ${id}:`, error);
process.exit(1);
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import {
startProxyProcess,
stopProxyProcess,
stopAllProxyProcesses
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
// Type definitions
interface ProxyOptions {
port?: number;
ignoreProxyCertificate?: boolean;
}
/**
* Start a local proxy server that forwards to an upstream proxy
* @param upstreamProxyUrl The upstream proxy URL (protocol://[username:password@]host:port)
* @param options Optional configuration
* @returns Promise resolving to the local proxy URL
*/
export async function startProxy(
upstreamProxyUrl: string,
options: ProxyOptions = {}
): Promise<string> {
const config = await startProxyProcess(upstreamProxyUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreProxyCertificate,
});
return config.localUrl || `http://localhost:${config.localPort}`;
}
/**
* Stop a specific proxy by its upstream URL
* @param upstreamProxyUrl The upstream proxy URL to stop
* @returns Promise resolving to true if proxy was found and stopped, false otherwise
*/
export async function stopProxy(upstreamProxyUrl: string): Promise<boolean> {
// Find all proxies with this upstream URL
const configs = listProxyConfigs().filter(
config => config.upstreamUrl === upstreamProxyUrl
);
if (configs.length === 0) {
return false;
}
// Stop all matching proxies
let success = true;
for (const config of configs) {
const stopped = await stopProxyProcess(config.id);
if (!stopped) {
success = false;
}
}
return success;
}
/**
* Get a list of all active proxy upstream URLs
* @returns Array of upstream proxy URLs
*/
export function getActiveProxies(): string[] {
return listProxyConfigs().map(config => config.upstreamUrl);
}
/**
* Stop all active proxies
* @returns Promise that resolves when all proxies are stopped
*/
export async function stopAllProxies(): Promise<void> {
await stopAllProxyProcesses();
}
+15 -14
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.3.3",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -35,6 +35,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-deep-link": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "~2.3.0",
"@tauri-apps/plugin-opener": "^2.2.7",
@@ -48,35 +49,35 @@
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.0",
"tailwind-merge": "^3.3.1",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.28.0",
"@eslint/js": "^9.29.0",
"@next/eslint-plugin-next": "^15.3.3",
"@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/postcss": "^4.1.10",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.30",
"@types/react": "^19.1.6",
"@types/node": "^24.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-react": "^4.5.1",
"eslint": "^9.28.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.29.0",
"eslint-config-next": "^15.3.3",
"eslint-plugin-react-hooks": "^5.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"tailwindcss": "^4.1.8",
"lint-staged": "^16.1.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.33.1"
"typescript-eslint": "^8.34.0"
},
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix",
"eslint --cache --fix"
],
+772 -719
View File
File diff suppressed because it is too large Load Diff
+140 -48
View File
@@ -13,9 +13,9 @@ dependencies = [
[[package]]
name = "adler2"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
@@ -411,9 +411,9 @@ checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
[[package]]
name = "bytemuck"
version = "1.23.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422"
[[package]]
name = "byteorder"
@@ -518,9 +518,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.26"
version = "1.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
dependencies = [
"jobserver",
"libc",
@@ -556,9 +556,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "cfg_aliases"
@@ -993,13 +993,16 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.3.3"
version = "0.5.1"
dependencies = [
"async-trait",
"base64 0.22.1",
"core-foundation 0.10.1",
"directories",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"lazy_static",
"objc2 0.6.1",
"objc2-app-kit",
@@ -1015,9 +1018,13 @@ dependencies = [
"tauri-plugin-macos-permissions",
"tauri-plugin-opener",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tempfile",
"tokio",
"tokio-test",
"tower",
"tower-http",
"urlencoding",
"windows",
"winreg",
"wiremock",
@@ -1097,9 +1104,9 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enumflags2"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
@@ -1107,9 +1114,9 @@ dependencies = [
[[package]]
name = "enumflags2_derive"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
@@ -1727,9 +1734,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
@@ -1794,6 +1801,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2262,9 +2275,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.172"
version = "0.2.173"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb"
[[package]]
name = "libloading"
@@ -2381,9 +2394,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "memchr"
version = "2.7.4"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "memoffset"
@@ -2401,10 +2414,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.8"
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
@@ -3141,9 +3164,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.7.1"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
checksum = "3d77244ce2d584cd84f6a15f86195b8c9b2a0dfbfd817c09e0464244091a58ed"
dependencies = [
"base64 0.22.1",
"indexmap 2.9.0",
@@ -3279,9 +3302,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.32.0"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
@@ -3419,9 +3442,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "redox_syscall"
version = "0.5.12"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
dependencies = [
"bitflags 2.9.1",
]
@@ -3437,6 +3460,26 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "regex"
version = "1.11.1"
@@ -3468,9 +3511,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.19"
version = "0.12.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3485,12 +3528,10 @@ dependencies = [
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
@@ -3563,9 +3604,9 @@ dependencies = [
[[package]]
name = "rustc-demangle"
version = "0.1.24"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc_version"
@@ -3667,6 +3708,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
@@ -3825,15 +3878,16 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.12.0"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"serde",
"serde_derive",
"serde_json",
@@ -3843,9 +3897,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.12.0"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77"
dependencies = [
"darling",
"proc-macro2",
@@ -4292,7 +4346,7 @@ dependencies = [
"glob",
"heck 0.5.0",
"json-patch",
"schemars",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
@@ -4352,7 +4406,7 @@ dependencies = [
"anyhow",
"glob",
"plist",
"schemars",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
@@ -4408,7 +4462,7 @@ dependencies = [
"dunce",
"glob",
"percent-encoding",
"schemars",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
@@ -4446,7 +4500,7 @@ dependencies = [
"objc2-app-kit",
"objc2-foundation 0.3.1",
"open",
"schemars",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
@@ -4468,7 +4522,7 @@ dependencies = [
"open",
"os_pipe",
"regex",
"schemars",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
@@ -4478,6 +4532,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d0e07b40fb2eb13778e30778f5979347a2bf30e1b9d47f78ff7fe92d2e4b3d"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.12",
"tracing",
"windows-sys 0.59.0",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.6.0"
@@ -4550,7 +4620,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"schemars",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
@@ -4869,14 +4939,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -5020,6 +5100,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@@ -5050,6 +5136,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5386,9 +5478,9 @@ dependencies = [
[[package]]
name = "windows"
version = "0.61.1"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core",
@@ -5454,9 +5546,9 @@ dependencies = [
[[package]]
name = "windows-link"
version = "0.1.1"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-numerics"
+10 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.3.3"
version = "0.5.1"
description = "Simple Yet Powerful Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
@@ -37,6 +37,10 @@ base64 = "0.22"
zip = "4"
async-trait = "0.1"
futures-util = "0.3"
urlencoding = "2.1"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation="0.10"
@@ -61,6 +65,11 @@ windows = { version = "0.61", features = [
tempfile = "3.13.0"
tokio-test = "0.4.4"
wiremock = "0.6"
hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace"] }
[features]
# by default Tauri runs in production mode
+30
View File
@@ -6,6 +6,8 @@
<string>Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<key>CFBundleDisplayName</key>
<string>Donut Browser</string>
<key>CFBundleName</key>
@@ -24,5 +26,33 @@
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut Browser</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>HTML document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.html</string>
<string>public.xhtml</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Web site URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
</array>
</dict>
</plist>
+1 -12
View File
@@ -1,14 +1,3 @@
function FindProxyForURL(url, host) {
const proxyString = "{{proxy_url}}";
// Split the proxy string to get the credentials part
const parts = proxyString.split(" ")[1].split("@");
if (parts.length > 1) {
const credentials = parts[0];
const encodedCredentials = encodeURIComponent(credentials);
// Replace the original credentials with encoded ones
return proxyString.replace(credentials, encodedCredentials);
}
return proxyString;
return "{{proxy_url}}";
}
+1 -1
View File
@@ -17,7 +17,7 @@ fn main() {
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
// For nightly builds, use commit hash
// For nightly builds, use timestamp format or fallback to commit hash
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
} else {
+4
View File
@@ -19,6 +19,10 @@
"shell:allow-spawn",
"shell:allow-stdin-write",
"deep-link:default",
"deep-link:allow-register",
"deep-link:allow-unregister",
"deep-link:allow-is-registered",
"deep-link:allow-get-current",
"dialog:default",
"dialog:allow-open",
"macos-permissions:default",
+2 -2
View File
@@ -1443,7 +1443,7 @@ mod tests {
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"name": "Release v1.81.9 (Chromium 137.0.7151.104)",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
@@ -1476,7 +1476,7 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(releases[0].is_nightly);
assert!(!releases[0].is_nightly); // "Release v1.81.9 (Chromium 137.0.7151.104)" starts with "Release" so it should be stable
}
#[tokio::test]
+1 -1
View File
@@ -152,7 +152,7 @@ impl AppAutoUpdater {
async fn fetch_app_releases(
&self,
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
let url = "https://api.github.com/repos/zhom/donutbrowser/releases?per_page=100";
let response = self
.client
.get(url)
+78 -71
View File
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use tauri::Emitter;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpdateNotification {
@@ -45,32 +46,32 @@ impl AutoUpdater {
pub async fn check_for_updates(
&self,
) -> Result<Vec<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
// Check if auto-updates are enabled
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if !settings.auto_updates_enabled {
return Ok(Vec::new());
}
let mut notifications = Vec::new();
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser
let profiles = self
.browser_runner
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let mut notifications = Vec::new();
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser type
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
for profile in profiles {
// Only check supported browsers
if !self
.version_service
.is_browser_supported(&profile.browser)
.unwrap_or(false)
{
continue;
}
browser_profiles
.entry(profile.browser.clone())
.or_default()
.push(profile);
}
// Check each browser type
for (browser, profiles) in browser_profiles {
// Get cached versions first, then try to fetch if needed
let versions = if let Some(cached) = self
@@ -97,7 +98,23 @@ impl AutoUpdater {
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
notifications.push(update);
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there are 50+ new versions
// Count how many versions are newer than the current profile version
let newer_versions_count = versions
.iter()
.filter(|v| self.is_version_newer(&v.version, &profile.version))
.count();
if newer_versions_count >= 50 {
notifications.push(update);
} else {
println!("Skipping chromium update notification: only {newer_versions_count} new versions (need 50+)");
}
} else {
notifications.push(update);
}
}
}
}
@@ -105,6 +122,50 @@ impl AutoUpdater {
Ok(notifications)
}
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
// Check for browser updates and trigger auto-downloads
match self.check_for_updates().await {
Ok(update_notifications) => {
if !update_notifications.is_empty() {
println!(
"Found {} browser updates to auto-download",
update_notifications.len()
);
// Trigger automatic downloads for each update
for notification in update_notifications {
println!(
"Auto-downloading {} version {}",
notification.browser, notification.new_version
);
// Emit a custom event to trigger auto-download
let auto_update_event = serde_json::json!({
"browser": notification.browser,
"new_version": notification.new_version,
"notification_id": notification.id,
"affected_profiles": notification.affected_profiles
});
if let Err(e) = app_handle.emit("browser-auto-update-available", &auto_update_event) {
eprintln!(
"Failed to emit auto-update event for {}: {e}",
notification.browser
);
} else {
println!("Emitted auto-update event for {}", notification.browser);
}
}
} else {
println!("No browser updates needed");
}
}
Err(e) => {
eprintln!("Failed to check for browser updates: {e}");
}
}
}
/// Check if a specific profile has an available update
fn check_profile_update(
&self,
@@ -181,43 +242,6 @@ impl AutoUpdater {
result
}
/// Mark download as auto-update
pub fn mark_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Remove auto-update download tracking
pub fn remove_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Check if download is marked as auto-update
pub fn is_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
@@ -448,27 +472,9 @@ pub async fn complete_browser_update_with_auto_update(
}
#[tauri::command]
pub async fn mark_auto_update_download(browser: String, version: String) -> Result<(), String> {
pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
let updater = AutoUpdater::new();
updater
.mark_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to mark auto-update download: {e}"))
}
#[tauri::command]
pub async fn remove_auto_update_download(browser: String, version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.remove_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to remove auto-update download: {e}"))
}
#[tauri::command]
pub async fn is_auto_update_download(browser: String, version: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
updater
.is_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to check auto-update download: {e}"))
updater.check_for_updates_with_progress(&app_handle).await;
}
#[cfg(test)]
@@ -484,6 +490,7 @@ mod tests {
process_id: None,
proxy: None,
last_launch: None,
release_type: "stable".to_string(),
}
}
+9
View File
@@ -9,6 +9,8 @@ pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -631,6 +633,7 @@ impl Browser for ChromiumBrowser {
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
];
// Add proxy configuration if provided
@@ -860,6 +863,8 @@ mod tests {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
assert!(proxy.enabled);
@@ -873,6 +878,8 @@ mod tests {
proxy_type: "socks5".to_string(),
host: "proxy.example.com".to_string(),
port: 1080,
username: None,
password: None,
};
assert_eq!(socks_proxy.proxy_type, "socks5");
@@ -949,6 +956,8 @@ mod tests {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
// Test that it can be serialized (implements Serialize)
+242 -128
View File
@@ -27,6 +27,12 @@ pub struct BrowserProfile {
pub process_id: Option<u32>,
#[serde(default)]
pub last_launch: Option<u64>,
#[serde(default = "default_release_type")]
pub release_type: String, // "stable" or "nightly"
}
fn default_release_type() -> String {
"stable".to_string()
}
// Platform-specific modules
@@ -642,12 +648,12 @@ mod windows {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// On Windows, TOR and Mullvad browsers can sometimes accept URLs via command line
// even with -no-remote, by launching a new instance that hands off to existing one
let browser = create_browser(browser_type);
let browser = create_browser(browser_type.clone());
let executable_path = browser
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let mut cmd = Command::new(executable_path);
let mut cmd = Command::new(&executable_path);
cmd.args(["-profile", &profile.profile_path, url]);
// Set working directory
@@ -685,7 +691,7 @@ mod windows {
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let mut cmd = Command::new(executable_path);
let mut cmd = Command::new(&executable_path);
cmd.args([
&format!("--user-data-dir={}", profile.profile_path),
"--new-window",
@@ -704,7 +710,7 @@ mod windows {
if !output.status.success() {
// Try fallback without --new-window
let mut fallback_cmd = Command::new(executable_path);
let mut fallback_cmd = Command::new(&executable_path);
fallback_cmd.args([&format!("--user-data-dir={}", profile.profile_path), url]);
if let Some(parent_dir) = browser_dir
@@ -1049,6 +1055,7 @@ impl BrowserRunner {
name: &str,
browser: &str,
version: &str,
release_type: &str,
proxy: Option<ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Check if a profile with this name already exists (case insensitive)
@@ -1075,6 +1082,7 @@ impl BrowserRunner {
proxy: proxy.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
};
// Save profile info
@@ -1091,11 +1099,12 @@ impl BrowserRunner {
Ok(profile)
}
pub fn update_profile_proxy(
pub async fn update_profile_proxy(
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
proxy: Option<ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
let profiles_dir = self.get_profiles_dir();
let profile_file = profiles_dir.join(format!(
"{}.json",
@@ -1111,30 +1120,97 @@ impl BrowserRunner {
let content = fs::read_to_string(&profile_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
// Check if browser is running to manage proxy accordingly
let browser_is_running = profile.process_id.is_some()
&& self
.check_browser_status(app_handle.clone(), &profile)
.await?;
// If browser is running, stop existing proxy
if browser_is_running && profile.proxy.is_some() {
if let Some(pid) = profile.process_id {
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
}
}
// Update proxy settings
profile.proxy = proxy.clone();
// Save the updated profile
self.save_profile(&profile)?;
self
.save_profile(&profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to save profile: {e}").into()
})?;
// Get internal proxy if the browser is running
let internal_proxy = if let Some(pid) = profile.process_id {
PROXY_MANAGER.get_proxy_settings(pid)
// Handle proxy startup/configuration
if let Some(proxy_settings) = &proxy {
if proxy_settings.enabled && browser_is_running {
// Browser is running and proxy is enabled, start new proxy
if let Some(pid) = profile.process_id {
match PROXY_MANAGER
.start_proxy(app_handle.clone(), proxy_settings, pid, Some(profile_name))
.await
{
Ok(internal_proxy_settings) => {
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
// Apply the proxy settings with the internal proxy to the profile directory
browser_runner
.apply_proxy_settings_to_profile(
&profile_path,
proxy_settings,
Some(&internal_proxy_settings),
)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
println!("Successfully started proxy for profile: {}", profile.name);
// Give the proxy a moment to fully start up
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
Some(internal_proxy_settings)
}
Err(e) => {
eprintln!("Failed to start proxy: {e}");
// Apply proxy settings without internal proxy
self
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
None
}
}
} else {
// No PID available, apply proxy settings without internal proxy
self
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
None
}
} else {
// Proxy disabled or browser not running, just apply settings
self
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
None
}
} else {
// No proxy settings, disable proxy
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
None
};
// Apply proxy settings if provided
if let Some(proxy_settings) = &proxy {
self.apply_proxy_settings_to_profile(
&profile_path,
proxy_settings,
internal_proxy.as_ref(),
)?;
} else {
self.disable_proxy_settings_in_profile(&profile_path)?;
}
Ok(profile)
}
@@ -1177,6 +1253,14 @@ impl BrowserRunner {
// Update version
profile.version = version.to_string();
// Update the release_type based on the version and browser
profile.release_type =
if crate::api_client::is_browser_version_nightly(&profile.browser, version, None) {
"nightly".to_string()
} else {
"stable".to_string()
};
// Save the updated profile
self.save_profile(&profile)?;
@@ -1193,9 +1277,13 @@ impl BrowserRunner {
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
fn cleanup_unused_binaries_internal(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Load current profiles
let profiles = self.list_profiles()?;
let profiles = self
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
// Load registry
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()?;
@@ -1216,23 +1304,21 @@ impl BrowserRunner {
vec![
// Disable default browser check
"user_pref(\"browser.shell.checkDefaultBrowser\", false);".to_string(),
// Disable automatic updates
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
"user_pref(\"app.update.mode\", 0);".to_string(),
"user_pref(\"app.update.mode\", 2);".to_string(),
"user_pref(\"app.update.promptWaitTime\", 0);".to_string(),
"user_pref(\"app.update.service.enabled\", false);".to_string(),
"user_pref(\"app.update.silent\", false);".to_string(),
// Disable update checking entirely
"user_pref(\"app.update.silent\", true);".to_string(),
"user_pref(\"app.update.checkInstallTime\", false);".to_string(),
"user_pref(\"app.update.url\", \"\");".to_string(),
"user_pref(\"app.update.url.manual\", \"\");".to_string(),
"user_pref(\"app.update.url.details\", \"\");".to_string(),
// Disable background update downloads
"user_pref(\"app.update.url.override\", \"\");".to_string(),
"user_pref(\"app.update.interval\", 9999999999);".to_string(),
"user_pref(\"app.update.background.interval\", 9999999999);".to_string(),
"user_pref(\"app.update.download.attemptOnce\", false);".to_string(),
"user_pref(\"app.update.idletime\", -1);".to_string(),
// Additional update-related preferences for completeness
"user_pref(\"security.tls.insecure_fallback_hosts\", \"\");".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
]
}
@@ -1249,9 +1335,10 @@ impl BrowserRunner {
preferences.extend(self.get_common_firefox_preferences());
if proxy.enabled {
// Create PAC file from template
let template_path = Path::new("assets/template.pac");
let pac_content = fs::read_to_string(template_path)?;
// Use embedded PAC template instead of reading from file
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
return "{{proxy_url}}";
}"#;
// Format proxy URL based on type and whether we have an internal proxy
let proxy_url = if let Some(internal) = internal_proxy {
@@ -1269,7 +1356,7 @@ impl BrowserRunner {
};
// Replace placeholders in PAC file
let pac_content = pac_content
let pac_content = PAC_TEMPLATE
.replace("{{proxy_url}}", &proxy_url)
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
@@ -1388,9 +1475,9 @@ impl BrowserRunner {
// Continue anyway, the error might not be critical
}
// Get launch arguments
// Get launch arguments (proxy settings will be handled later if needed)
let browser_args = browser
.create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url)
.create_launch_args(&profile.profile_path, None, url)
.expect("Failed to create launch arguments");
// Launch browser using platform-specific method
@@ -1826,7 +1913,10 @@ impl BrowserRunner {
if let Ok(settings) = settings_manager.load_settings() {
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background after profile deletion
let _ = self.cleanup_unused_binaries_internal();
// Ignore errors since this is not critical for profile deletion
if let Err(e) = self.cleanup_unused_binaries_internal() {
println!("Warning: Failed to cleanup unused binaries: {e}");
}
}
}
@@ -1988,14 +2078,13 @@ impl BrowserRunner {
if !proxy_active {
// Browser is running but proxy is not - restart the proxy
if let Some((upstream_url, _preferred_port)) =
PROXY_MANAGER.get_profile_proxy_info(&inner_profile.name)
if let Some(proxy_settings) = PROXY_MANAGER.get_profile_proxy_info(&inner_profile.name)
{
// Restart the proxy with the same configuration
match PROXY_MANAGER
.start_proxy(
app_handle,
&upstream_url,
&proxy_settings,
inner_profile.process_id.unwrap(),
Some(&inner_profile.name),
)
@@ -2127,11 +2216,12 @@ pub fn create_browser_profile(
name: String,
browser: String,
version: String,
release_type: String,
proxy: Option<ProxySettings>,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.create_profile(&name, &browser, &version, proxy)
.create_profile(&name, &browser, &version, &release_type, proxy)
.map_err(|e| format!("Failed to create profile: {e}"))
}
@@ -2151,9 +2241,53 @@ pub async fn launch_browser_profile(
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
// If the profile has proxy settings, we need to start the proxy first
// and update the profile with proxy settings before launching
let profile_for_launch = profile.clone();
if let Some(proxy) = &profile.proxy {
if proxy.enabled {
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
// Start the proxy first
match PROXY_MANAGER
.start_proxy(app_handle.clone(), proxy, temp_pid, Some(&profile.name))
.await
{
Ok(internal_proxy_settings) => {
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
// Apply the proxy settings with the internal proxy to the profile directory
browser_runner
.apply_proxy_settings_to_profile(&profile_path, proxy, Some(&internal_proxy_settings))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
println!("Successfully started proxy for profile: {}", profile.name);
// Give the proxy a moment to fully start up
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
Err(e) => {
eprintln!("Failed to start proxy: {e}");
// Still continue with browser launch, but without proxy
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
// Apply proxy settings without internal proxy
browser_runner
.apply_proxy_settings_to_profile(&profile_path, proxy, None)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
}
}
// Launch browser or open URL in existing instance
let updated_profile = browser_runner
.launch_or_open_url(app_handle.clone(), &profile, url)
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url)
.await
.map_err(|e| {
// Check if this is an architecture compatibility issue
@@ -2166,32 +2300,17 @@ pub async fn launch_browser_profile(
format!("Failed to launch browser or open URL: {e}")
})?;
// If the profile has proxy settings, start a proxy for it
// Now update the proxy with the correct PID if we have one
if let Some(proxy) = &profile.proxy {
if proxy.enabled {
// Get the process ID
if let Some(pid) = updated_profile.process_id {
// Start a proxy for the upstream URL
let upstream_url = format!("{}://{}:{}", proxy.proxy_type, proxy.host, proxy.port);
// Start the proxy
match PROXY_MANAGER
.start_proxy(app_handle.clone(), &upstream_url, pid, Some(&profile.name))
.await
{
Ok(internal_proxy_settings) => {
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
// Apply the proxy settings with the internal proxy
browser_runner
.apply_proxy_settings_to_profile(&profile_path, proxy, Some(&internal_proxy_settings))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
if let Some(actual_pid) = updated_profile.process_id {
// Update the proxy manager with the correct PID
match PROXY_MANAGER.update_proxy_pid(1u32, actual_pid) {
Ok(()) => {
println!("Updated proxy PID mapping from temp (1) to actual PID: {actual_pid}");
}
Err(e) => {
eprintln!("Failed to start proxy: {e}");
// Continue without proxy
eprintln!("Failed to update proxy PID mapping: {e}");
}
}
}
@@ -2202,13 +2321,15 @@ pub async fn launch_browser_profile(
}
#[tauri::command]
pub fn update_profile_proxy(
pub async fn update_profile_proxy(
app_handle: tauri::AppHandle,
profile_name: String,
proxy: Option<ProxySettings>,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.update_profile_proxy(&profile_name, proxy)
.update_profile_proxy(app_handle, &profile_name, proxy)
.await
.map_err(|e| format!("Failed to update profile: {e}"))
}
@@ -2539,11 +2660,18 @@ pub fn create_browser_profile_new(
name: String,
browser_str: String,
version: String,
release_type: String,
proxy: Option<ProxySettings>,
) -> Result<BrowserProfile, String> {
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile(name, browser_type.as_str().to_string(), version, proxy)
create_browser_profile(
name,
browser_type.as_str().to_string(),
version,
release_type,
proxy,
)
}
#[tauri::command]
@@ -2564,6 +2692,17 @@ pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String
Ok(registry.get_downloaded_versions(&browser_str))
}
#[tauri::command]
pub async fn get_browser_release_types(
browser_str: String,
) -> Result<crate::browser_version_service::BrowserReleaseTypes, String> {
let service = BrowserVersionService::new();
service
.get_browser_release_types(&browser_str)
.await
.map_err(|e| format!("Failed to get browser release types: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2609,7 +2748,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
let profile = runner
.create_profile("Test Profile", "firefox", "139.0", None)
.create_profile("Test Profile", "firefox", "139.0", "stable", None)
.unwrap();
assert_eq!(profile.name, "Test Profile");
@@ -2628,6 +2767,8 @@ mod tests {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
let profile = runner
@@ -2635,6 +2776,7 @@ mod tests {
"Test Profile with Proxy",
"firefox",
"139.0",
"stable",
Some(proxy.clone()),
)
.unwrap();
@@ -2652,7 +2794,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
let profile = runner
.create_profile("Test Save Load", "firefox", "139.0", None)
.create_profile("Test Save Load", "firefox", "139.0", "stable", None)
.unwrap();
// Save the profile
@@ -2666,43 +2808,13 @@ mod tests {
assert_eq!(profiles[0].version, "139.0");
}
#[test]
fn test_update_profile_proxy() {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile without proxy
let profile = runner
.create_profile("Test Update Proxy", "firefox", "139.0", None)
.unwrap();
assert!(profile.proxy.is_none());
// Update with proxy
let proxy = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "192.168.1.1".to_string(),
port: 1080,
};
let updated_profile = runner
.update_profile_proxy("Test Update Proxy", Some(proxy.clone()))
.unwrap();
assert!(updated_profile.proxy.is_some());
let profile_proxy = updated_profile.proxy.unwrap();
assert_eq!(profile_proxy.proxy_type, "socks5");
assert_eq!(profile_proxy.host, "192.168.1.1");
assert_eq!(profile_proxy.port, 1080);
}
#[test]
fn test_rename_profile() {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile
let _ = runner
.create_profile("Original Name", "firefox", "139.0", None)
.create_profile("Original Name", "firefox", "139.0", "stable", None)
.unwrap();
// Rename profile
@@ -2722,7 +2834,7 @@ mod tests {
// Create profile
let _ = runner
.create_profile("To Delete", "firefox", "139.0", None)
.create_profile("To Delete", "firefox", "139.0", "stable", None)
.unwrap();
// Verify profile exists
@@ -2743,7 +2855,13 @@ mod tests {
// Create profile with spaces and special characters
let profile = runner
.create_profile("Test Profile With Spaces", "firefox", "139.0", None)
.create_profile(
"Test Profile With Spaces",
"firefox",
"139.0",
"stable",
None,
)
.unwrap();
// Profile path should use snake_case
@@ -2756,13 +2874,13 @@ mod tests {
// Create multiple profiles
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.create_profile("Profile 1", "firefox", "139.0", "stable", None)
.unwrap();
let _ = runner
.create_profile("Profile 2", "chromium", "1465660", None)
.create_profile("Profile 2", "chromium", "1465660", "stable", None)
.unwrap();
let _ = runner
.create_profile("Profile 3", "brave", "v1.81.9", None)
.create_profile("Profile 3", "brave", "v1.81.9", "stable", None)
.unwrap();
// List profiles
@@ -2781,10 +2899,10 @@ mod tests {
// Test that we can't rename to an existing profile name
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.create_profile("Profile 1", "firefox", "139.0", "stable", None)
.unwrap();
let _ = runner
.create_profile("Profile 2", "firefox", "139.0", None)
.create_profile("Profile 2", "firefox", "139.0", "stable", None)
.unwrap();
// Try to rename profile2 to profile1's name (should fail)
@@ -2793,31 +2911,19 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn test_error_handling() {
let (runner, _temp_dir) = create_test_browser_runner();
// Test updating non-existent profile
let result = runner.update_profile_proxy("Non Existent", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
// Test deleting non-existent profile
let result = runner.delete_profile("Non Existent");
assert!(result.is_ok()); // Delete should be idempotent
// Test renaming non-existent profile
let result = runner.rename_profile("Non Existent", "New Name");
assert!(result.is_err());
}
#[test]
fn test_firefox_default_browser_preferences() {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile without proxy
let profile = runner
.create_profile("Test Firefox Prefs", "firefox", "139.0", None)
.create_profile(
"Test Firefox Preferences",
"firefox",
"139.0",
"stable",
None,
)
.unwrap();
// Check that user.js file was created with default browser preference
@@ -2838,10 +2944,18 @@ mod tests {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
let profile_with_proxy = runner
.create_profile("Test Firefox Prefs Proxy", "firefox", "139.0", Some(proxy))
.create_profile(
"Test Firefox Preferences Proxy",
"firefox",
"139.0",
"stable",
Some(proxy),
)
.unwrap();
// Check that user.js file contains both proxy settings and default browser preference
+79 -2
View File
@@ -17,6 +17,12 @@ pub struct BrowserVersionsResult {
pub total_versions_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserReleaseTypes {
pub stable: Option<String>,
pub nightly: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadInfo {
pub url: String,
@@ -136,6 +142,67 @@ impl BrowserVersionService {
self.api_client.is_cache_expired(browser)
}
/// Get latest stable and nightly versions for a browser (cached first)
pub async fn get_browser_release_types(
&self,
browser: &str,
) -> Result<BrowserReleaseTypes, Box<dyn std::error::Error + Send + Sync>> {
// Try to get from cache first
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
// For Chromium, only return stable since all releases are stable
if browser == "chromium" {
let latest_stable = cached_versions.first().map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: None,
});
}
let latest_stable = cached_versions
.iter()
.find(|v| !v.is_prerelease)
.map(|v| v.version.clone());
let latest_nightly = cached_versions
.iter()
.find(|v| v.is_prerelease)
.map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: latest_nightly,
});
}
// Fallback to fetching if not cached
// For Chromium, only return stable since all releases are stable
if browser == "chromium" {
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
let latest_stable = detailed_versions.first().map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: None,
});
}
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
let latest_stable = detailed_versions
.iter()
.find(|v| !v.is_prerelease)
.map(|v| v.version.clone());
let latest_nightly = detailed_versions
.iter()
.find(|v| v.is_prerelease)
.map(|v| v.version.clone());
Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: latest_nightly,
})
}
/// Fetch browser versions with optional caching
pub async fn fetch_browser_versions(
&self,
@@ -299,6 +366,8 @@ impl BrowserVersionService {
let releases = self.fetch_zen_releases_detailed(true).await?;
merged_versions
.into_iter()
// Filter out twilight releases at the detailed level too
.filter(|version| version.to_lowercase() != "twilight")
.map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
@@ -385,7 +454,9 @@ impl BrowserVersionService {
})
.collect()
}
_ => return Err(format!("Unsupported browser: {browser}").into()),
_ => {
return Err(format!("Unsupported browser: {browser}").into());
}
};
Ok(detailed_info)
@@ -746,7 +817,13 @@ impl BrowserVersionService {
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_zen_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
Ok(
releases
.into_iter()
.filter(|r| r.tag_name.to_lowercase() != "twilight")
.map(|r| r.tag_name)
.collect(),
)
}
async fn fetch_zen_releases_detailed(
+1 -2
View File
@@ -70,7 +70,6 @@ mod windows {
use winreg::RegKey;
const APP_NAME: &str = "DonutBrowser";
const APP_EXECUTABLE: &str = "DonutBrowser.exe";
const PROG_ID: &str = "DonutBrowser.HTML";
pub fn is_default_browser() -> Result<bool, String> {
@@ -279,7 +278,7 @@ mod windows {
Ok(())
}
fn register_html_file_association(exe_path: &str) -> Result<(), String> {
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Register .html and .htm file associations
+5 -5
View File
@@ -27,7 +27,7 @@ impl DownloadedBrowsersRegistry {
Self::default()
}
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
if !registry_path.exists() {
@@ -39,7 +39,7 @@ impl DownloadedBrowsersRegistry {
Ok(registry)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
// Ensure parent directory exists
@@ -52,7 +52,7 @@ impl DownloadedBrowsersRegistry {
Ok(())
}
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
@@ -146,7 +146,7 @@ impl DownloadedBrowsersRegistry {
&mut self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(info) = self.remove_browser(browser, version) {
// Clean up any files that might have been left behind
if info.file_path.exists() {
@@ -180,7 +180,7 @@ impl DownloadedBrowsersRegistry {
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let active_set: std::collections::HashSet<(String, String)> =
active_profiles.iter().cloned().collect();
let mut cleaned_up = Vec::new();
+64 -56
View File
@@ -1018,78 +1018,86 @@ impl Extractor {
}
#[cfg(target_os = "windows")]
async fn find_windows_executable_recursive(
&self,
dir: &Path,
fn find_windows_executable_recursive<'a>(
&'a self,
dir: &'a Path,
depth: usize,
max_depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
if depth > max_depth {
return Err("Maximum search depth reached".into());
}
if let Ok(entries) = fs::read_dir(dir) {
let mut dirs_to_search = Vec::new();
// First pass: look for .exe files in current directory
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
{
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
// Check if it's a browser executable
if file_name.contains("firefox")
|| file_name.contains("chrome")
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("tor")
|| file_name.contains("mullvad")
|| file_name.contains("browser")
{
return Ok(path);
}
} else if path.is_dir() {
// Collect directories for later search
dirs_to_search.push(path);
}
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>>
+ Send
+ 'a,
>,
> {
Box::pin(async move {
if depth > max_depth {
return Err("Maximum search depth reached".into());
}
// Second pass: search subdirectories
for subdir in dirs_to_search {
if let Ok(result) = self
.find_windows_executable_recursive(&subdir, depth + 1, max_depth)
.await
{
return Ok(result);
}
}
// Third pass: if no browser-specific executable found, return any .exe
if let Ok(entries) = fs::read_dir(dir) {
let mut dirs_to_search = Vec::new();
// First pass: look for .exe files in current directory
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
{
return Ok(path);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
// Check if it's a browser executable
if file_name.contains("firefox")
|| file_name.contains("chrome")
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("tor")
|| file_name.contains("mullvad")
|| file_name.contains("browser")
{
return Ok(path);
}
} else if path.is_dir() {
// Collect directories for later search
dirs_to_search.push(path);
}
}
// Second pass: search subdirectories
for subdir in dirs_to_search {
if let Ok(result) = self
.find_windows_executable_recursive(&subdir, depth + 1, max_depth)
.await
{
return Ok(result);
}
}
// Third pass: if no browser-specific executable found, return any .exe
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
{
return Ok(path);
}
}
}
}
}
Err("No executable found".into())
Err("No executable found".into())
})
}
#[cfg(target_os = "linux")]
+96 -37
View File
@@ -1,4 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -27,10 +28,10 @@ extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile_new, delete_profile,
download_browser, fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
launch_browser_profile, list_browser_profiles, rename_profile, update_profile_proxy,
update_profile_version,
fetch_browser_versions_with_count_cached_first, get_browser_release_types,
get_downloaded_browser_versions, get_supported_browsers, is_browser_supported_on_platform,
kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile,
update_profile_proxy, update_profile_version,
};
use settings_manager::{
@@ -48,8 +49,7 @@ use version_updater::{
use auto_updater::{
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
remove_auto_update_download,
is_browser_disabled_for_update,
};
use app_auto_updater::{
@@ -111,20 +111,16 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
// Window is visible, emit event directly
println!("Main window is visible, emitting show-profile-selector event");
app
.emit("show-profile-selector", url.clone())
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
let _ = window.show();
let _ = window.set_focus();
} else {
// Window not visible yet - add to pending URLs
println!("Main window not visible, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
println!("Main window exists");
// Try to show and focus the window first
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
app
.emit("show-profile-selector", url.clone())
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
} else {
// Window doesn't exist yet - add to pending URLs
println!("Main window doesn't exist, adding URL to pending list");
@@ -137,6 +133,8 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
#[tauri::command]
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
println!("check_and_handle_startup_url called");
let pending_urls = {
let mut pending = PENDING_URLS.lock().unwrap();
let urls = pending.clone();
@@ -144,12 +142,24 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
urls
};
println!("Found {} pending URLs", pending_urls.len());
if !pending_urls.is_empty() {
println!(
"Handling {} pending URLs from frontend request",
pending_urls.len()
);
// Ensure the main window is visible and focused
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
// Give the window a moment to become visible
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
for url in pending_urls {
println!("Emitting show-profile-selector event for URL: {url}");
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
@@ -166,11 +176,23 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
if let Some(url) = startup_url.clone() {
println!("Found startup URL in command line: {url}");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone());
}
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
println!("Single instance triggered with args: {args:?}");
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.setup(|app| {
@@ -180,7 +202,10 @@ pub fn run() {
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false);
.fullscreen(false)
.center()
.focused(true)
.visible(true);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
@@ -199,16 +224,27 @@ pub fn run() {
#[cfg(any(windows, target_os = "linux"))]
{
// For Windows and Linux, register all deep links at runtime for development
app.deep_link().register_all()?;
if let Err(e) = app.deep_link().register_all() {
eprintln!("Failed to register deep links: {e}");
}
}
#[cfg(target_os = "macos")]
{
// On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() {
eprintln!(
"Note: Deep link registration failed on macOS (this is normal for production): {e}"
);
}
}
// Handle deep links - this works for both scenarios:
// 1. App is running and URL is opened
// 2. App is not running and URL causes app to launch
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
println!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
println!("Deep link received: {url_string}");
@@ -226,25 +262,50 @@ pub fn run() {
}
});
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
println!("Processing startup URL from command line: {startup_url}");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
eprintln!("Failed to handle startup URL: {e}");
}
});
}
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
let mut updater_guard = version_updater.lock().await;
// Set the app handle
updater_guard.set_app_handle(app_handle).await;
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
// Start the background updates
updater_guard.start_background_updates().await;
// Run startup check without holding the lock
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
eprintln!("Failed to start background updates: {e}");
}
}
});
// Start the background update task separately
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
// Check for app updates at startup
let app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Add a small delay to ensure the app is fully loaded
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
println!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::new();
match updater.check_for_updates().await {
@@ -284,6 +345,7 @@ pub fn run() {
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions,
get_browser_release_types,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -306,9 +368,6 @@ pub fn run() {
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
mark_auto_update_download,
remove_auto_update_download,
is_auto_update_download,
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
+42 -51
View File
@@ -84,17 +84,15 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
// Primary location in AppData\Roaming
if let Some(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")?);
}
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
if let Some(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() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
}
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() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
}
}
@@ -129,12 +127,11 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(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")?);
}
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")?);
}
}
@@ -168,10 +165,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
@@ -198,10 +194,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let chromium_dir = local_app_data.join("Chromium/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let chromium_dir = local_app_data.join("Chromium/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
@@ -228,10 +223,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[cfg(target_os = "linux")]
@@ -264,17 +258,15 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
// Primary location in AppData\Roaming
if let Some(app_data) = self.base_dirs.data_dir() {
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
}
let app_data = self.base_dirs.data_dir();
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
// Also check common installation locations
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
if mullvad_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
if mullvad_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
}
}
@@ -304,10 +296,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = self.base_dirs.data_dir() {
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
let app_data = self.base_dirs.data_dir();
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "linux")]
@@ -378,17 +369,16 @@ impl ProfileImporter {
}
// Also check AppData directories if available
if let Some(app_data) = self.base_dirs.data_dir() {
let tor_app_data =
app_data.join("TorBrowser/Browser/TorBrowser/Data/Browser/profile.default");
if tor_app_data.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: "TOR Browser - AppData Profile".to_string(),
path: tor_app_data.to_string_lossy().to_string(),
description: "TOR Browser profile from AppData".to_string(),
});
}
let app_data = self.base_dirs.data_dir();
let tor_app_data =
app_data.join("TorBrowser/Browser/TorBrowser/Data/Browser/profile.default");
if tor_app_data.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: "TOR Browser - AppData Profile".to_string(),
path: tor_app_data.to_string_lossy().to_string(),
description: "TOR Browser profile from AppData".to_string(),
});
}
}
@@ -696,6 +686,7 @@ impl ProfileImporter {
proxy: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
};
// Save the profile metadata
+595 -23
View File
@@ -11,7 +11,9 @@ use crate::browser::ProxySettings;
pub struct ProxyInfo {
pub id: String,
pub local_url: String,
pub upstream_url: String,
pub upstream_host: String,
pub upstream_port: u16,
pub upstream_type: String,
pub local_port: u16,
}
@@ -19,7 +21,7 @@ pub struct ProxyInfo {
pub struct ProxyManager {
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
// Store proxy info by profile name for persistence across browser restarts
profile_proxies: Mutex<HashMap<String, (String, u16)>>, // Maps profile name to (upstream_url, port)
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
}
impl ProxyManager {
@@ -30,11 +32,11 @@ impl ProxyManager {
}
}
// Start a proxy for a given upstream URL and associate it with a browser process ID
// Start a proxy for given proxy settings and associate it with a browser process ID
pub async fn start_proxy(
&self,
app_handle: tauri::AppHandle,
upstream_url: &str,
proxy_settings: &ProxySettings,
browser_pid: u32,
profile_name: Option<&str>,
) -> Result<ProxySettings, String> {
@@ -44,9 +46,11 @@ impl ProxyManager {
if let Some(proxy) = proxies.get(&browser_pid) {
return Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
proxy_type: proxy.upstream_type.clone(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy.local_port,
username: None,
password: None,
});
}
}
@@ -54,31 +58,62 @@ impl ProxyManager {
// Check if we have a preferred port for this profile
let preferred_port = if let Some(name) = profile_name {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(name).map(|(_, port)| *port)
profile_proxies.get(name).and_then(|settings| {
// Find existing proxy with same settings to reuse port
let active_proxies = self.active_proxies.lock().unwrap();
active_proxies
.values()
.find(|p| {
p.upstream_host == settings.host
&& p.upstream_port == settings.port
&& p.upstream_type == settings.proxy_type
})
.map(|p| p.local_port)
})
} else {
None
};
// Start a new proxy using the nodecar binary
// Start a new proxy using the nodecar binary with the correct CLI interface
let mut nodecar = app_handle
.shell()
.sidecar("nodecar")
.unwrap()
.map_err(|e| format!("Failed to create sidecar: {e}"))?
.arg("proxy")
.arg("start")
.arg("-u")
.arg(upstream_url);
.arg("--host")
.arg(&proxy_settings.host)
.arg("--proxy-port")
.arg(proxy_settings.port.to_string())
.arg("--type")
.arg(&proxy_settings.proxy_type);
// Add credentials if provided
if let Some(username) = &proxy_settings.username {
nodecar = nodecar.arg("--username").arg(username);
}
if let Some(password) = &proxy_settings.password {
nodecar = nodecar.arg("--password").arg(password);
}
// If we have a preferred port, use it
if let Some(port) = preferred_port {
nodecar = nodecar.arg("-p").arg(port.to_string());
nodecar = nodecar.arg("--port").arg(port.to_string());
}
let output = nodecar.output().await.unwrap();
// Execute the command and wait for it to complete
// The nodecar binary should start the worker and then exit
let output = nodecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {stderr}"));
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!(
"Proxy start failed - stdout: {stdout}, stderr: {stderr}"
));
}
let json_string =
@@ -95,15 +130,13 @@ impl ProxyManager {
.as_str()
.ok_or("Missing local URL")?
.to_string();
let upstream_url_str = json["upstreamUrl"]
.as_str()
.ok_or("Missing upstream URL")?
.to_string();
let proxy_info = ProxyInfo {
id: id.to_string(),
local_url,
upstream_url: upstream_url_str.clone(),
upstream_host: proxy_settings.host.clone(),
upstream_port: proxy_settings.port,
upstream_type: proxy_settings.proxy_type.clone(),
local_port,
};
@@ -116,15 +149,17 @@ impl ProxyManager {
// Store the profile proxy info for persistence
if let Some(name) = profile_name {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), (upstream_url_str, local_port));
profile_proxies.insert(name.to_string(), proxy_settings.clone());
}
// Return proxy settings for the browser
Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy_info.local_port,
username: None,
password: None,
})
}
@@ -169,19 +204,556 @@ impl ProxyManager {
proxies.get(&browser_pid).map(|proxy| ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy.local_port,
username: None,
password: None,
})
}
// Get stored proxy info for a profile
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(profile_name).cloned()
}
// Update the PID mapping for an existing proxy
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
let mut proxies = self.active_proxies.lock().unwrap();
if let Some(proxy_info) = proxies.remove(&old_pid) {
proxies.insert(new_pid, proxy_info);
Ok(())
} else {
Err(format!("No proxy found for PID {old_pid}"))
}
}
}
// Create a singleton instance of the proxy manager
lazy_static::lazy_static! {
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use tokio::time::sleep;
// Mock HTTP server for testing
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
// Helper function to build nodecar binary for testing
async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
let project_root = PathBuf::from(cargo_manifest_dir)
.parent()
.unwrap()
.to_path_buf();
let nodecar_dir = project_root.join("nodecar");
let nodecar_dist = nodecar_dir.join("dist");
let nodecar_binary = nodecar_dist.join("nodecar");
// Check if binary already exists
if nodecar_binary.exists() {
return Ok(nodecar_binary);
}
// Build the nodecar binary
println!("Building nodecar binary for tests...");
// Install dependencies
let install_status = Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&nodecar_dir)
.status()?;
if !install_status.success() {
return Err("Failed to install nodecar dependencies".into());
}
// Determine the target architecture
let target = if cfg!(target_arch = "aarch64") && cfg!(target_os = "macos") {
"build:mac-aarch64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "macos") {
"build:mac-x86_64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "linux") {
"build:linux-x64"
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "linux") {
"build:linux-arm64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "windows") {
"build:win-x64"
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "windows") {
"build:win-arm64"
} else {
return Err("Unsupported target architecture for nodecar build".into());
};
// Build the binary
let build_status = Command::new("pnpm")
.args(["run", target])
.current_dir(&nodecar_dir)
.status()?;
if !build_status.success() {
return Err("Failed to build nodecar binary".into());
}
if !nodecar_binary.exists() {
return Err("Nodecar binary was not created successfully".into());
}
Ok(nodecar_binary)
}
#[tokio::test]
async fn test_proxy_manager_profile_persistence() {
let proxy_manager = ProxyManager::new();
let proxy_settings = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
username: None,
password: None,
};
// Test profile proxy info storage
{
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
}
// Test retrieval
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.proxy_type, "socks5");
assert_eq!(retrieved.host, "127.0.0.1");
assert_eq!(retrieved.port, 1080);
// Test non-existent profile
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
assert!(non_existent.is_none());
}
#[tokio::test]
async fn test_proxy_manager_active_proxy_tracking() {
let proxy_manager = ProxyManager::new();
let proxy_info = ProxyInfo {
id: "test_proxy_123".to_string(),
local_url: "http://localhost:8080".to_string(),
upstream_host: "proxy.example.com".to_string(),
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: 8080,
};
let browser_pid = 54321u32;
// Add active proxy
{
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
active_proxies.insert(browser_pid, proxy_info.clone());
}
// Test retrieval of proxy settings
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
assert!(proxy_settings.is_some());
let settings = proxy_settings.unwrap();
assert!(settings.enabled);
assert_eq!(settings.host, "127.0.0.1");
assert_eq!(settings.port, 8080);
// Test non-existent browser PID
let non_existent = proxy_manager.get_proxy_settings(99999);
assert!(non_existent.is_none());
}
#[test]
fn test_proxy_settings_validation() {
// Test valid proxy settings
let valid_settings = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
};
assert!(valid_settings.enabled);
assert_eq!(valid_settings.proxy_type, "http");
assert!(!valid_settings.host.is_empty());
assert!(valid_settings.port > 0);
// Test disabled proxy settings
let disabled_settings = ProxySettings {
enabled: false,
proxy_type: "http".to_string(),
host: "".to_string(),
port: 0,
username: None,
password: None,
};
assert!(!disabled_settings.enabled);
}
#[tokio::test]
async fn test_proxy_manager_concurrent_access() {
use std::sync::Arc;
let proxy_manager = Arc::new(ProxyManager::new());
let mut handles = vec![];
// Spawn multiple tasks that access the proxy manager concurrently
for i in 0..10 {
let pm = proxy_manager.clone();
let handle = tokio::spawn(async move {
let browser_pid = (1000 + i) as u32;
let proxy_info = ProxyInfo {
id: format!("proxy_{i}"),
local_url: format!("http://127.0.0.1:{}", 8000 + i),
upstream_host: "127.0.0.1".to_string(),
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
};
// Add proxy
{
let mut active_proxies = pm.active_proxies.lock().unwrap();
active_proxies.insert(browser_pid, proxy_info);
}
// Read proxy
let settings = pm.get_proxy_settings(browser_pid);
assert!(settings.is_some());
browser_pid
});
handles.push(handle);
}
// Wait for all tasks to complete
let results: Vec<u32> = futures_util::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
// Verify all browser PIDs were processed
assert_eq!(results.len(), 10);
for (i, &browser_pid) in results.iter().enumerate() {
assert_eq!(browser_pid, (1000 + i) as u32);
}
}
// Integration test that actually builds and uses nodecar binary
#[tokio::test]
async fn test_proxy_integration_with_real_nodecar() -> Result<(), Box<dyn std::error::Error>> {
// This test requires nodecar to be built and available
let nodecar_path = ensure_nodecar_binary().await?;
// Start a mock upstream HTTP server
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
let upstream_addr = upstream_listener.local_addr()?;
// Spawn upstream server
let server_handle = tokio::spawn(async move {
while let Ok((stream, _)) = upstream_listener.accept().await {
let io = TokioIo::new(stream);
tokio::task::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
io,
service_fn(|_req| async {
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK"))))
}),
)
.await;
});
}
});
// Wait for server to start
sleep(Duration::from_millis(100)).await;
// Test nodecar proxy start command directly (using the binary itself, not node)
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg(upstream_addr.ip().to_string())
.arg("--proxy-port")
.arg(upstream_addr.port().to_string())
.arg("--type")
.arg("http");
// Set a timeout for the command
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
// Verify proxy configuration
assert!(config["id"].is_string());
assert!(config["localPort"].is_number());
assert!(config["localUrl"].is_string());
let proxy_id = config["id"].as_str().unwrap();
let local_port = config["localPort"].as_u64().unwrap();
// Wait for proxy worker to start
println!("Waiting for proxy worker to start...");
tokio::time::sleep(Duration::from_secs(3)).await;
// Test that the local port is listening
let mut port_test = Command::new("nc");
port_test
.arg("-z")
.arg("127.0.0.1")
.arg(local_port.to_string());
let port_output = port_test.output()?;
if port_output.status.success() {
println!("Proxy is listening on port {local_port}");
} else {
println!("Warning: Proxy port {local_port} is not listening");
}
// Test stopping the proxy
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let stop_output =
tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??;
assert!(stop_output.status.success());
println!("Integration test passed: nodecar proxy start/stop works correctly");
} else {
let stderr = String::from_utf8(output.stderr)?;
eprintln!("Nodecar failed: {stderr}");
return Err(format!("Nodecar command failed: {stderr}").into());
}
// Clean up server
server_handle.abort();
Ok(())
}
// Test that validates the command line arguments are constructed correctly
#[test]
fn test_proxy_command_construction() {
let proxy_settings = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "proxy.example.com".to_string(),
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
};
// Test command arguments match expected format
let _expected_args = [
"proxy",
"start",
"--host",
"proxy.example.com",
"--proxy-port",
"8080",
"--type",
"http",
"--username",
"user",
"--password",
"pass",
];
// This test verifies the argument structure without actually running the command
assert_eq!(proxy_settings.host, "proxy.example.com");
assert_eq!(proxy_settings.port, 8080);
assert_eq!(proxy_settings.proxy_type, "http");
assert_eq!(proxy_settings.username.as_ref().unwrap(), "user");
assert_eq!(proxy_settings.password.as_ref().unwrap(), "pass");
}
// Test the CLI detachment specifically - ensure the CLI exits properly
#[tokio::test]
async fn test_cli_exits_after_proxy_start() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test that the CLI exits quickly with a mock upstream
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("httpbin.org")
.arg("--proxy-port")
.arg("80")
.arg("--type")
.arg("http");
let start_time = std::time::Instant::now();
let output = tokio::time::timeout(Duration::from_secs(3), async { cmd.output() }).await;
match output {
Ok(Ok(cmd_output)) => {
let execution_time = start_time.elapsed();
println!("CLI completed in {execution_time:?}");
// Should complete very quickly if properly detached
assert!(
execution_time < Duration::from_secs(3),
"CLI took too long ({execution_time:?}), should exit immediately after starting worker"
);
if cmd_output.status.success() {
let stdout = String::from_utf8(cmd_output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
// Clean up - try to stop the proxy
if let Some(proxy_id) = config["id"].as_str() {
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
}
}
println!("CLI detachment test passed - CLI exited in {execution_time:?}");
}
Ok(Err(e)) => {
return Err(format!("Command execution failed: {e}").into());
}
Err(_) => {
return Err("CLI command timed out - this indicates improper detachment".into());
}
}
Ok(())
}
// Test that validates proper CLI detachment behavior
#[tokio::test]
async fn test_cli_detachment_behavior() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test that the CLI command exits quickly even with a real upstream
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("httpbin.org") // Use a known good endpoint
.arg("--proxy-port")
.arg("80")
.arg("--type")
.arg("http");
let start_time = std::time::Instant::now();
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
let execution_time = start_time.elapsed();
// Command should complete very quickly if properly detached
assert!(
execution_time < Duration::from_secs(5),
"CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment"
);
println!("CLI detachment test: command completed in {execution_time:?}");
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap();
// Clean up
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
println!("CLI detachment test passed");
} else {
// Even if the upstream fails, the CLI should still exit quickly
println!("CLI command failed but exited quickly as expected");
}
Ok(())
}
// Test that validates URL encoding for special characters in credentials
#[tokio::test]
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test with credentials that include special characters
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("test.example.com")
.arg("--proxy-port")
.arg("8080")
.arg("--type")
.arg("http")
.arg("--username")
.arg("user@domain.com") // Contains @ symbol
.arg("--password")
.arg("pass word!"); // Contains space and special character
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let upstream_url = config["upstreamUrl"].as_str().unwrap();
println!("Generated upstream URL: {upstream_url}");
// Verify that special characters are properly encoded
assert!(upstream_url.contains("user%40domain.com"));
// The password may be encoded as "pass%20word!" or "pass%20word%21" depending on implementation
assert!(upstream_url.contains("pass%20word"));
println!("URL encoding test passed - special characters handled correctly");
// Clean up
let proxy_id = config["id"].as_str().unwrap();
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
} else {
// This test might fail if the upstream doesn't exist, but we mainly care about URL construction
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
println!("Command failed (expected for non-existent upstream):");
println!("Stdout: {stdout}");
println!("Stderr: {stderr}");
// The important thing is that the command completed quickly
println!("URL encoding test completed - credentials should be properly encoded");
}
Ok(())
}
}
+15 -38
View File
@@ -4,7 +4,7 @@ use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::browser_version_service::BrowserVersionService;
use crate::version_updater;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
@@ -25,40 +25,25 @@ impl Default for TableSortingSettings {
pub struct AppSettings {
#[serde(default)]
pub set_as_default_browser: bool,
#[serde(default = "default_show_settings_on_startup")]
#[serde(default)]
pub show_settings_on_startup: bool,
#[serde(default = "default_theme")]
pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool,
#[serde(default = "default_auto_delete_unused_binaries")]
#[serde(default)]
pub auto_delete_unused_binaries: bool,
}
fn default_show_settings_on_startup() -> bool {
true
}
fn default_theme() -> String {
"system".to_string()
}
fn default_auto_updates_enabled() -> bool {
true
}
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
set_as_default_browser: false,
show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(),
auto_delete_unused_binaries: default_auto_delete_unused_binaries(),
show_settings_on_startup: true,
theme: "system".to_string(),
auto_delete_unused_binaries: true,
}
}
}
@@ -216,7 +201,9 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
}
#[tauri::command]
pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
pub async fn clear_all_version_cache_and_refetch(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let api_client = ApiClient::new();
// Clear all cache first
@@ -224,23 +211,13 @@ pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
// Trigger auto-fetch for all supported browsers
let service = BrowserVersionService::new();
let supported_browsers = service.get_supported_browsers();
let updater = version_updater::get_version_updater();
let updater_guard = updater.lock().await;
for browser in supported_browsers {
// Start background fetch for each browser (don't wait for completion)
let service_clone = BrowserVersionService::new();
let browser_clone = browser.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
.fetch_browser_versions_detailed(&browser_clone, false)
.await
{
eprintln!("Background version fetch failed for {browser_clone}: {e}");
}
});
}
updater_guard
.trigger_manual_update(&app_handle)
.await
.map_err(|e| format!("Failed to trigger version update: {e}"))?;
Ok(())
}
+114 -66
View File
@@ -1,4 +1,3 @@
use crate::browser_version_service::BrowserVersionService;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -8,7 +7,10 @@ use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio::time::{interval, Interval};
use tokio::time::interval;
use crate::auto_updater::AutoUpdater;
use crate::browser_version_service::BrowserVersionService;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersionUpdateProgress {
@@ -46,22 +48,23 @@ impl Default for BackgroundUpdateState {
pub struct VersionUpdater {
version_service: BrowserVersionService,
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
update_interval: Interval,
auto_updater: AutoUpdater,
app_handle: Option<tauri::AppHandle>,
}
impl VersionUpdater {
pub fn new() -> Self {
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
Self {
version_service: BrowserVersionService::new(),
app_handle: Arc::new(Mutex::new(None)),
update_interval,
auto_updater: AutoUpdater::new(),
app_handle: None,
}
}
pub fn set_app_handle(&mut self, app_handle: tauri::AppHandle) {
self.app_handle = Some(app_handle);
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
@@ -143,11 +146,6 @@ impl VersionUpdater {
should_update
}
pub async fn set_app_handle(&self, app_handle: tauri::AppHandle) {
let mut handle = self.app_handle.lock().await;
*handle = Some(app_handle);
}
pub async fn check_and_run_startup_update(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -157,15 +155,10 @@ impl VersionUpdater {
return Ok(());
}
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
if let Some(handle) = app_handle {
if let Some(ref app_handle) = self.app_handle {
println!("Running startup version update...");
match self.update_all_browser_versions(&handle).await {
match self.update_all_browser_versions(app_handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
@@ -191,7 +184,9 @@ impl VersionUpdater {
Ok(())
}
pub async fn start_background_updates(&mut self) {
pub async fn start_background_updates(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!(
"Starting background version update service (checking every 5 minutes for 3-hour intervals)"
);
@@ -201,41 +196,54 @@ impl VersionUpdater {
eprintln!("Startup version update failed: {e}");
}
Ok(())
}
pub async fn run_background_task() {
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
self.update_interval.tick().await;
update_interval.tick().await;
// Check if we should run an update based on persistent state
if !Self::should_run_background_update() {
continue;
}
// Check if we have an app handle
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
println!("Starting background version update...");
if let Some(handle) = app_handle {
println!("Starting background version update...");
// Get the updater instance for this update cycle
let updater = get_version_updater();
let result = {
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
updater_guard.update_all_browser_versions(app_handle).await
} else {
Err("App handle not available for background update".into())
}
}; // Release the lock here
match self.update_all_browser_versions(&handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
match result {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
} else {
println!("Background version update completed successfully");
}
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
} else {
println!("Background version update completed successfully");
}
Err(e) => {
eprintln!("Background version update failed: {e}");
}
Err(e) => {
eprintln!("Background version update failed: {e}");
// Emit error event
// Try to emit error event if we have an app handle
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers: 0,
@@ -244,11 +252,9 @@ impl VersionUpdater {
browser_new_versions: 0,
status: "error".to_string(),
};
let _ = handle.emit("version-update-progress", &progress);
let _ = app_handle.emit("version-update-progress", &progress);
}
}
} else {
println!("App handle not available, skipping background update");
}
}
}
@@ -259,7 +265,7 @@ impl VersionUpdater {
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Starting background version update for all browsers");
let browsers = [
let all_browsers = [
"firefox",
"firefox-developer",
"mullvad-browser",
@@ -269,10 +275,28 @@ impl VersionUpdater {
"tor-browser",
];
// Filter browsers to only include those supported on the current platform
let browsers: Vec<&str> = all_browsers
.iter()
.filter(|browser| {
self
.version_service
.is_browser_supported(browser)
.unwrap_or(false)
})
.copied()
.collect();
let total_browsers = browsers.len();
let mut results = Vec::new();
let mut total_new_versions = 0;
println!(
"Updating {} supported browsers (filtered from {} total)",
browsers.len(),
all_browsers.len()
);
// Emit start event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
@@ -282,10 +306,36 @@ impl VersionUpdater {
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit start progress: {e}");
} else {
println!("Emitted start progress event");
}
for (index, browser) in browsers.iter().enumerate() {
// Check if individual browser cache is expired before updating
println!(
"Processing browser {} ({}/{}): {}",
browser,
index + 1,
total_browsers,
browser
);
// Emit progress for current browser
let progress = VersionUpdateProgress {
current_browser: browser.to_string(),
total_browsers,
completed_browsers: index,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "updating".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit progress for {browser}: {e}");
} else {
println!("Emitted progress event for browser: {browser}");
}
if !self.version_service.should_update_cache(browser) {
println!("Skipping {browser} - cache is still fresh");
@@ -300,18 +350,7 @@ impl VersionUpdater {
continue;
}
println!("Updating versions for browser: {browser}");
// Emit progress for current browser
let progress = VersionUpdateProgress {
current_browser: browser.to_string(),
total_browsers,
completed_browsers: index,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
println!("Fetching new versions for browser: {browser}");
let result = self.update_browser_versions(browser).await;
@@ -343,9 +382,14 @@ impl VersionUpdater {
}
// Small delay between browsers to avoid overwhelming APIs
tokio::time::sleep(Duration::from_millis(500)).await;
tokio::time::sleep(Duration::from_millis(200)).await;
}
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
// Emit completion event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
@@ -355,9 +399,13 @@ impl VersionUpdater {
browser_new_versions: 0,
status: "completed".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit completion progress: {e}");
} else {
println!("Emitted completion progress event");
}
println!("Background version update completed. Found {total_new_versions} new versions total");
println!("Version update completed. Found {total_new_versions} new versions total");
Ok(results)
}
+4 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.3.3",
"version": "0.5.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -61,8 +61,9 @@
},
"plugins": {
"deep-link": {
"schemes": ["http", "https"],
"domains": []
"desktop": {
"schemes": ["http", "https"]
}
}
}
}
+24 -4
View File
@@ -25,10 +25,12 @@ import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"
import { usePermissions } from "@/hooks/use-permissions";
import type { PermissionType } from "@/hooks/use-permissions";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaDownload } from "react-icons/fa";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
@@ -80,7 +82,10 @@ export default function Home() {
}
}, []);
// Auto-update functionality - pass loadProfiles to refresh profiles after updates
// Version updater for handling version fetching progress events and auto-updates
useVersionUpdater();
// Auto-update functionality - use the existing hook for compatibility
const updateNotifications = useUpdateNotifications(loadProfiles);
const { checkForUpdates, isUpdating } = updateNotifications;
@@ -102,6 +107,18 @@ export default function Home() {
useAppUpdateNotifications();
// For some reason, app.deep_link().get_current() is not working properly
const checkCurrentUrl = useCallback(async () => {
try {
const currentUrl = await getCurrent();
if (currentUrl && currentUrl.length > 0) {
void handleUrlOpen(currentUrl[0]);
}
} catch (error) {
console.error("Failed to check current URL:", error);
}
}, []);
useEffect(() => {
void loadProfilesWithUpdateCheck();
@@ -113,6 +130,7 @@ export default function Home() {
// Check for startup URLs (when app was launched as default browser)
void checkStartupUrls();
void checkCurrentUrl();
// Set up periodic update checks (every 30 minutes)
const updateInterval = setInterval(
@@ -125,7 +143,7 @@ export default function Home() {
return () => {
clearInterval(updateInterval);
};
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
}, [loadProfilesWithUpdateCheck, checkForUpdates, checkCurrentUrl]);
// Check permissions when they are initialized
useEffect(() => {
@@ -287,6 +305,7 @@ export default function Home() {
name: string;
browserStr: BrowserTypeString;
version: string;
releaseType: string;
proxy?: ProxySettings;
}) => {
setError(null);
@@ -298,6 +317,7 @@ export default function Home() {
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
},
);
@@ -460,7 +480,7 @@ export default function Home() {
);
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
@@ -513,7 +533,7 @@ export default function Home() {
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<CardContent>
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
+102 -55
View File
@@ -1,6 +1,7 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -12,9 +13,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import type { BrowserProfile } from "@/types";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { LuTriangleAlert } from "react-icons/lu";
@@ -32,16 +33,18 @@ export function ChangeVersionDialog({
profile,
onVersionChanged,
}: ChangeVersionDialogProps) {
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [selectedReleaseType, setSelectedReleaseType] = useState<
"stable" | "nightly" | null
>(null);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>({});
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [showDowngradeWarning, setShowDowngradeWarning] = useState(false);
const [acknowledgeDowngrade, setAcknowledgeDowngrade] = useState(false);
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
@@ -49,49 +52,73 @@ export function ChangeVersionDialog({
useEffect(() => {
if (isOpen && profile) {
setSelectedVersion(profile.version);
// Set current release type based on profile
setSelectedReleaseType(profile.release_type as "stable" | "nightly");
setAcknowledgeDowngrade(false);
void loadVersions(profile.browser);
void loadReleaseTypes(profile.browser);
void loadDownloadedVersions(profile.browser);
}
}, [isOpen, profile, loadVersions, loadDownloadedVersions]);
}, [isOpen, profile, loadDownloadedVersions]);
const loadReleaseTypes = async (browser: string) => {
setIsLoadingReleaseTypes(true);
try {
const releaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
setReleaseTypes(releaseTypes);
} catch (error) {
console.error("Failed to load release types:", error);
} finally {
setIsLoadingReleaseTypes(false);
}
};
useEffect(() => {
if (profile && selectedVersion) {
// Check if this is a downgrade
const currentVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === profile.version,
);
const selectedVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === selectedVersion,
);
// If selected version has a higher index, it's older (downgrade)
if (
profile &&
selectedReleaseType &&
selectedReleaseType !== profile.release_type
) {
// For simplicity, we'll show downgrade warning when switching from stable to nightly
// since nightly versions might be considered "downgrades" in terms of stability
const isDowngrade =
currentVersionIndex !== -1 &&
selectedVersionIndex !== -1 &&
selectedVersionIndex > currentVersionIndex;
profile.release_type === "stable" && selectedReleaseType === "nightly";
setShowDowngradeWarning(isDowngrade);
if (!isDowngrade) {
setAcknowledgeDowngrade(false);
}
}
}, [selectedVersion, profile, availableVersions]);
}, [selectedReleaseType, profile]);
const handleDownload = async () => {
if (!profile || !selectedVersion) return;
await downloadBrowser(profile.browser, selectedVersion);
if (!profile || !selectedReleaseType) return;
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
await downloadBrowser(profile.browser, version);
};
const handleVersionChange = async () => {
if (!profile || !selectedVersion) return;
if (!profile || !selectedReleaseType) return;
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
setIsUpdating(true);
try {
await invoke("update_profile_version", {
profileName: profile.name,
version: selectedVersion,
version,
});
onVersionChanged();
onClose();
@@ -102,10 +129,15 @@ export function ChangeVersionDialog({
}
};
const selectedVersion =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
const canUpdate =
profile &&
selectedVersion &&
selectedVersion !== profile.version &&
selectedReleaseType &&
selectedReleaseType !== profile.release_type &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!showDowngradeWarning || acknowledgeDowngrade);
@@ -116,50 +148,65 @@ export function ChangeVersionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Change Browser Version</DialogTitle>
<DialogTitle>Change Release Type</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Profile:</Label>
<div className="p-2 bg-muted rounded text-sm">{profile.name}</div>
<div className="p-2 text-sm rounded bg-muted">{profile.name}</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Current Version:</Label>
<div className="p-2 bg-muted rounded text-sm">
{profile.version}
<Label className="text-sm font-medium">Current Release:</Label>
<div className="p-2 text-sm capitalize rounded bg-muted">
{profile.release_type} ({profile.version})
</div>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>New Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{!releaseTypes.stable || !releaseTypes.nightly ? (
<Alert>
<AlertDescription>
Only {profile.release_type} releases are available for{" "}
{getBrowserDisplayName(profile.browser)}.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-2">
<Label>New Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : (
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={profile.browser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
)}
</div>
)}
{/* Downgrade Warning */}
{showDowngradeWarning && (
<Alert className="border-orange-700">
<LuTriangleAlert className="h-4 w-4 text-orange-700" />
<LuTriangleAlert className="w-4 h-4 text-orange-700" />
<AlertTitle className="text-orange-700">
Downgrade Warning
Stability Warning
</AlertTitle>
<AlertDescription className="text-orange-700">
You are about to downgrade from version {profile.version} to{" "}
{selectedVersion}. This may lead to compatibility issues, data
loss, or unexpected behavior.
<div className="flex items-center space-x-2 mt-3">
You are about to switch from stable to nightly releases. Nightly
versions may be less stable and could contain bugs or incomplete
features.
<div className="flex items-center mt-3 space-x-2">
<Checkbox
id="acknowledge-downgrade"
checked={acknowledgeDowngrade}
@@ -187,7 +234,7 @@ export function ChangeVersionDialog({
}}
disabled={!canUpdate}
>
{isUpdating ? "Updating..." : "Update Version"}
{isUpdating ? "Updating..." : "Update Release Type"}
</LoadingButton>
</DialogFooter>
</DialogContent>
+145 -50
View File
@@ -1,6 +1,7 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -24,14 +25,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import type {
BrowserProfile,
BrowserReleaseTypes,
ProxySettings,
} from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "./ui/alert";
type BrowserTypeString =
| "mullvad-browser"
@@ -49,6 +54,7 @@ interface CreateProfileDialogProps {
name: string;
browserStr: BrowserTypeString;
version: string;
releaseType: string;
proxy?: ProxySettings;
}) => Promise<void>;
}
@@ -61,26 +67,32 @@ export function CreateProfileDialog({
const [profileName, setProfileName] = useState("");
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [selectedReleaseType, setSelectedReleaseType] = useState<
"stable" | "nightly" | null
>(null);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>({
stable: undefined,
nightly: undefined,
});
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
);
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
// Proxy settings
const [proxyEnabled, setProxyEnabled] = useState(false);
const [proxyType, setProxyType] = useState("http");
const [proxyHost, setProxyHost] = useState("");
const [proxyPort, setProxyPort] = useState(8080);
const [proxyUsername, setProxyUsername] = useState("");
const [proxyPassword, setProxyPassword] = useState("");
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
isDownloading,
downloadedVersions,
loadDownloadedVersions,
} = useBrowserDownload();
const {
@@ -108,29 +120,26 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
setSelectedVersion(null);
void loadVersions(selectedBrowser);
// Reset selected release type when browser changes
setSelectedReleaseType(null);
void loadReleaseTypes(selectedBrowser);
void loadDownloadedVersions(selectedBrowser);
}
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
}, [isOpen, selectedBrowser, loadDownloadedVersions]);
// Set default version when versions are loaded and no version is selected
// Set default release type when release types are loaded
useEffect(() => {
if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta)
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
setSelectedVersion(stableVersions[0].tag_name);
} else if (availableVersions.length > 0) {
// If no stable version found, select the first available version
setSelectedVersion(availableVersions[0].tag_name);
if (!selectedReleaseType && Object.keys(releaseTypes).length > 0) {
// First try to set stable if it exists
if (releaseTypes.stable) {
setSelectedReleaseType("stable");
}
// If stable doesn't exist but nightly does, set nightly as default
else if (releaseTypes.nightly && selectedBrowser !== "chromium") {
setSelectedReleaseType("nightly");
}
}
}, [availableVersions, selectedBrowser]);
}, [releaseTypes, selectedReleaseType, selectedBrowser]);
const loadExistingProfiles = async () => {
try {
@@ -141,9 +150,34 @@ export function CreateProfileDialog({
}
};
const loadReleaseTypes = async (browser: string) => {
try {
setIsLoadingReleaseTypes(true);
const types = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{
browserStr: browser,
},
);
setReleaseTypes(types);
} catch (error) {
console.error("Failed to load release types:", error);
toast.error("Failed to load available versions");
} finally {
setIsLoadingReleaseTypes(false);
}
};
const handleDownload = async () => {
if (!selectedBrowser || !selectedVersion) return;
await downloadBrowser(selectedBrowser, selectedVersion);
if (!selectedBrowser || !selectedReleaseType) return;
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
await downloadBrowser(selectedBrowser, version);
};
const validateProfileName = (name: string): string | null => {
@@ -176,7 +210,7 @@ export function CreateProfileDialog({
}, [selectedBrowser, proxyEnabled]);
const handleCreate = async () => {
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
if (!profileName.trim() || !selectedBrowser || !selectedReleaseType) return;
// Validate profile name
const nameError = validateProfileName(profileName);
@@ -185,6 +219,15 @@ export function CreateProfileDialog({
return;
}
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) {
toast.error("Selected release type is not available");
return;
}
setIsCreating(true);
try {
const proxy =
@@ -194,22 +237,27 @@ export function CreateProfileDialog({
proxy_type: proxyType,
host: proxyHost,
port: proxyPort,
username: proxyUsername || undefined,
password: proxyPassword || undefined,
}
: undefined;
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: selectedVersion,
version,
releaseType: selectedReleaseType,
proxy,
});
// Reset form
setProfileName("");
setSelectedVersion(null);
setSelectedReleaseType(null);
setProxyEnabled(false);
setProxyHost("");
setProxyPort(8080);
setProxyUsername("");
setProxyPassword("");
onClose();
} catch (error) {
console.error("Failed to create profile:", error);
@@ -221,22 +269,28 @@ export function CreateProfileDialog({
const nameError = profileName.trim()
? validateProfileName(profileName)
: null;
const selectedVersion =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
const canCreate =
profileName.trim() &&
selectedBrowser &&
selectedReleaseType &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!proxyEnabled || isProxyDisabled || (proxyHost && proxyPort)) &&
!nameError;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid overflow-y-scroll flex-1 gap-6 py-4 min-h-0">
{/* Profile Name */}
<div className="grid gap-2">
<Label htmlFor="profile-name">Profile Name</Label>
@@ -316,21 +370,37 @@ export function CreateProfileDialog({
</Select>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{selectedBrowser &&
(!releaseTypes.stable || !releaseTypes.nightly) ? (
<Alert>
<AlertDescription>
Only {(releaseTypes.stable && "Stable") ?? "Nightly"} releases
are available for {getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-2">
<Label>Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : (
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={selectedBrowser ?? ""}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
)}
</div>
)}
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
@@ -414,6 +484,31 @@ export function CreateProfileDialog({
max="65535"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={proxyUsername}
onChange={(e) => {
setProxyUsername(e.target.value);
}}
placeholder="Proxy username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={proxyPassword}
onChange={(e) => {
setProxyPassword(e.target.value);
}}
placeholder="Proxy password"
/>
</div>
</>
)}
</div>
+66 -22
View File
@@ -10,6 +10,7 @@
* - Progress bars for downloads/updates
* - Success/error states
* - Customizable icons and content
* - Auto-update notifications
*
* Usage Examples:
*
@@ -23,6 +24,11 @@
* });
* ```
*
* Auto-update toast:
* ```
* showAutoUpdateToast("Firefox", "125.0.1");
* ```
*
* Download progress toast:
* ```
* showToast({
@@ -47,6 +53,7 @@ import {
LuCheckCheck,
LuDownload,
LuRefreshCw,
LuRocket,
LuTriangleAlert,
} from "react-icons/lu";
@@ -90,6 +97,7 @@ interface VersionUpdateToastProps extends BaseToastProps {
current: number;
total: number;
found: number;
current_browser?: string;
};
}
@@ -138,6 +146,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
@@ -150,11 +162,33 @@ export function UnifiedToast(props: ToastProps) {
const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined;
// Check if this is an auto-update toast
const isAutoUpdate = title.includes("update started");
return (
<div className="flex items-start p-3 w-full bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
isAutoUpdate
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
}`}
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
>
<div className="mr-3 mt-0.5">
{isAutoUpdate ? (
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
) : (
getToastIcon(type, stage)
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight text-gray-900 dark:text-white">
<p
className={`text-sm font-medium leading-tight ${
isAutoUpdate
? "text-emerald-900 dark:text-emerald-100"
: "text-gray-900 dark:text-white"
}`}
>
{title}
</p>
@@ -181,26 +215,30 @@ export function UnifiedToast(props: ToastProps) {
)}
{/* Version update progress */}
{type === "version-update" && progress && "found" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
{progress.found} new versions found so far
</p>
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
{type === "version-update" &&
progress &&
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
{progress.current_browser && (
<>Looking for updates for {progress.current_browser}</>
)}
</p>
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
</div>
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
{progress.current}/{progress.total}
</span>
</div>
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
{progress.current}/{progress.total}
</span>
</div>
</div>
)}
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
@@ -220,7 +258,13 @@ export function UnifiedToast(props: ToastProps) {
{/* Description */}
{description && (
<p className="mt-1 text-xs leading-tight text-gray-600 dark:text-gray-300">
<p
className={`mt-1 text-xs leading-tight ${
isAutoUpdate
? "text-emerald-700 dark:text-emerald-300"
: "text-gray-600 dark:text-gray-300"
}`}
>
{description}
</p>
)}
+24
View File
@@ -0,0 +1,24 @@
export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
role="graphics-symbol img"
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<path
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
className="b"
/>
<path
d="M12 5.33c-3.68 0-6.67 2.98-6.67 6.67s2.98 6.67 6.67 6.67 6.67-2.98 6.67-6.67S15.69 5.33 12 5.33m0 12.05c-2.97 0-5.38-2.41-5.38-5.38S9.03 6.62 12 6.62s5.38 2.41 5.38 5.38-2.41 5.38-5.38 5.38"
className="b"
/>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18.2c-4.53 0-8.21-3.67-8.21-8.2S7.47 3.79 12 3.79s8.21 3.67 8.21 8.21-3.67 8.2-8.21 8.2"
className="b"
/>
</svg>
);
-12
View File
@@ -1,7 +1,6 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -90,17 +89,6 @@ export function PermissionDialog({
}
};
const getStatusBadge = (isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="text-green-800 bg-green-100">
Granted
</Badge>
);
}
return <Badge variant="secondary">Not Granted</Badge>;
};
const handleRequestPermission = async () => {
setIsRequesting(true);
try {
+91 -89
View File
@@ -1,6 +1,5 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -164,7 +163,7 @@ export function ProfilesDataTable({
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
return (
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -206,21 +205,34 @@ export function ProfilesDataTable({
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
className="p-0 h-auto font-semibold hover:bg-transparent"
>
Profile
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
<LuChevronDown className="ml-2 w-4 h-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
)}
</Button>
);
},
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const profile = row.original;
return profile.name.length > 15 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">{profile.name.slice(0, 15)}...</span>
</TooltipTrigger>
<TooltipContent>{profile.name}</TooltipContent>
</Tooltip>
) : (
profile.name
);
},
},
{
accessorKey: "browser",
@@ -232,15 +244,15 @@ export function ProfilesDataTable({
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
className="p-0 h-auto font-semibold hover:bg-transparent"
>
Browser
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
<LuChevronDown className="ml-2 w-4 h-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
)}
</Button>
);
@@ -248,10 +260,21 @@ export function ProfilesDataTable({
cell: ({ row }) => {
const browser: string = row.getValue("browser");
const IconComponent = getBrowserIcon(browser);
return (
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="h-4 w-4" />}
<span>{getBrowserDisplayName(browser)}</span>
const browserDisplayName = getBrowserDisplayName(browser);
return browserDisplayName.length > 15 ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-2 items-center">
{IconComponent && <IconComponent className="w-4 h-4" />}
<span>{browserDisplayName.slice(0, 15)}...</span>
</div>
</TooltipTrigger>
<TooltipContent>{browserDisplayName}</TooltipContent>
</Tooltip>
) : (
<div className="flex gap-2 items-center">
{IconComponent && <IconComponent className="w-4 h-4" />}
<span>{browserDisplayName}</span>
</div>
);
},
@@ -263,67 +286,33 @@ export function ProfilesDataTable({
},
},
{
accessorKey: "version",
header: "Version",
},
{
id: "status",
header: ({ column }) => {
const isSorted = column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</Button>
);
},
accessorKey: "release_type",
header: "Release",
cell: ({ row }) => {
const profile = row.original;
const isRunning = isClient && runningProfiles.has(profile.name);
const releaseType: string = row.getValue("release_type");
const isNightly = releaseType === "nightly";
return (
<div className="flex flex-col gap-1">
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs w-fit"
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isNightly
? "text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-200"
: "text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-200"
}`}
>
{isClient ? (isRunning ? "Running" : "Stopped") : "Loading..."}
</Badge>
{isClient && isRunning && profile.process_id && (
<span className="text-xs text-muted-foreground">
PID: {profile.process_id}
</span>
)}
{isNightly ? "Nightly" : "Stable"}
</span>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB) => {
// If not on client, sort by name only to ensure consistency
if (!isClient) {
return rowA.original.name.localeCompare(rowB.original.name);
}
const isRunningA = runningProfiles.has(rowA.original.name);
const isRunningB = runningProfiles.has(rowB.original.name);
// Running profiles come first, then stopped ones
// Secondary sort by profile name
if (isRunningA === isRunningB) {
return rowA.original.name.localeCompare(rowB.original.name);
}
return isRunningA ? -1 : 1;
sortingFn: (rowA, rowB, columnId) => {
const releaseA: string = rowA.getValue(columnId);
const releaseB: string = rowB.getValue(columnId);
// Sort with "stable" before "nightly"
if (releaseA === "stable" && releaseB === "nightly") return -1;
if (releaseA === "nightly" && releaseB === "stable") return 1;
return 0;
},
},
{
@@ -332,24 +321,30 @@ export function ProfilesDataTable({
cell: ({ row }) => {
const profile = row.original;
const hasProxy = profile.proxy?.enabled;
const regularText = hasProxy ? profile.proxy?.proxy_type : "Disabled";
const regularTooltipText = hasProxy
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
profile.proxy?.host
}:${profile.proxy?.port})`
: "No proxy configured";
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
{hasProxy && (
<CiCircleCheck className="h-4 w-4 text-green-500" />
<CiCircleCheck className="w-4 h-4 text-green-500" />
)}
<span className="text-sm text-muted-foreground">
{hasProxy ? profile.proxy?.proxy_type : "Disabled"}
{profile.browser === "tor-browser"
? "Not supported"
: regularText}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{hasProxy
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
profile.proxy?.host
}:${profile.proxy?.port})`
: "No proxy configured"}
{profile.browser === "tor-browser"
? "Proxies are not supported for TOR browser"
: regularTooltipText}
</TooltipContent>
</Tooltip>
);
@@ -363,16 +358,16 @@ export function ProfilesDataTable({
const isRunning = isClient && runningProfiles.has(profile.name);
const isBrowserUpdating = isClient && isUpdating(profile.browser);
return (
<div className="flex items-center justify-end">
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
className="p-0 w-8 h-8"
disabled={!isClient}
>
<span className="sr-only">Open menu</span>
<IoEllipsisHorizontal className="h-4 w-4" />
<IoEllipsisHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -384,16 +379,18 @@ export function ProfilesDataTable({
}}
disabled={!isClient || isBrowserUpdating}
>
Configure proxy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Change version
Configure Proxy
</DropdownMenuItem>
{!["chromium", "zen"].includes(profile.browser) && (
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Switch Release
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setProfileToRename(profile);
@@ -511,7 +508,7 @@ export function ProfilesDataTable({
<DialogTitle>Rename Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<div className="grid grid-cols-4 gap-4 items-center">
<Label htmlFor="name" className="text-right">
Name
</Label>
@@ -573,6 +570,11 @@ export function ProfilesDataTable({
setDeleteConfirmationName(e.target.value);
setDeleteError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleDelete();
}
}}
placeholder="Type the profile name here"
/>
</div>
+7 -14
View File
@@ -138,10 +138,6 @@ export function ProfileSelectorDialog({
const isRunning = runningProfiles.has(profile.name);
if (profile.browser === "tor-browser") {
const runningTorProfiles = profiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
);
// If another TOR profile is running, this one is not available
return "Only 1 instance can run at a time";
}
@@ -195,9 +191,6 @@ export function ProfileSelectorDialog({
};
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
const isSelectedProfileRunning = selectedProfile
? runningProfiles.has(selectedProfile)
: false;
// Check if the selected profile can be used for opening links
const canOpenWithSelectedProfile = () => {
@@ -225,19 +218,19 @@ export function ProfileSelectorDialog({
<div className="grid gap-4 py-4">
{url && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<Button
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
className="flex items-center gap-2"
className="flex gap-2 items-center"
>
<LuCopy className="h-3 w-3" />
<LuCopy className="w-3 h-3" />
Copy
</Button>
</div>
<div className="p-2 bg-muted rounded text-sm break-all">
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
</div>
</div>
@@ -290,14 +283,14 @@ export function ProfileSelectorDialog({
!canUseForLinks ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-3 py-1 px-2 rounded-lg hover:bg-accent cursor-pointer">
<div className="flex items-center gap-2">
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
<div className="flex gap-2 items-center">
{(() => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return IconComponent ? (
<IconComponent className="h-4 w-4" />
<IconComponent className="w-4 h-4" />
) : null;
})()}
</div>
+38 -1
View File
@@ -30,6 +30,8 @@ interface ProxySettings {
proxy_type: string;
host: string;
port: number;
username?: string;
password?: string;
}
interface ProxySettingsDialogProps {
@@ -52,6 +54,8 @@ export function ProxySettingsDialog({
proxy_type: initialSettings?.proxy_type ?? "http",
host: initialSettings?.host ?? "",
port: initialSettings?.port ?? 8080,
username: initialSettings?.username ?? "",
password: initialSettings?.password ?? "",
});
const [initialSettingsState, setInitialSettingsState] =
@@ -60,6 +64,8 @@ export function ProxySettingsDialog({
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
useEffect(() => {
@@ -69,6 +75,8 @@ export function ProxySettingsDialog({
proxy_type: initialSettings.proxy_type,
host: initialSettings.host,
port: initialSettings.port,
username: initialSettings.username ?? "",
password: initialSettings.password ?? "",
};
setSettings(newSettings);
setInitialSettingsState(newSettings);
@@ -78,6 +86,8 @@ export function ProxySettingsDialog({
proxy_type: "http",
host: "",
port: 80,
username: "",
password: "",
};
setSettings(defaultSettings);
setInitialSettingsState(defaultSettings);
@@ -94,7 +104,9 @@ export function ProxySettingsDialog({
settings.enabled !== initialSettingsState.enabled ||
settings.proxy_type !== initialSettingsState.proxy_type ||
settings.host !== initialSettingsState.host ||
settings.port !== initialSettingsState.port
settings.port !== initialSettingsState.port ||
settings.username !== initialSettingsState.username ||
settings.password !== initialSettingsState.password
);
};
@@ -214,6 +226,31 @@ export function ProxySettingsDialog({
max="65535"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username (optional)</Label>
<Input
id="username"
value={settings.username}
onChange={(e) => {
setSettings({ ...settings, username: e.target.value });
}}
placeholder="Proxy username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password (optional)</Label>
<Input
id="password"
type="password"
value={settings.password}
onChange={(e) => {
setSettings({ ...settings, password: e.target.value });
}}
placeholder="Proxy password"
/>
</div>
</>
)}
</div>
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes } from "@/types";
import { useState } from "react";
import { LuDownload } from "react-icons/lu";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
interface ReleaseTypeSelectorProps {
selectedReleaseType: "stable" | "nightly" | null;
onReleaseTypeSelect: (releaseType: "stable" | "nightly" | null) => void;
availableReleaseTypes: BrowserReleaseTypes;
browser: string;
isDownloading: boolean;
onDownload: () => void;
placeholder?: string;
showDownloadButton?: boolean;
downloadedVersions?: string[];
}
export function ReleaseTypeSelector({
selectedReleaseType,
onReleaseTypeSelect,
availableReleaseTypes,
browser,
isDownloading,
onDownload,
placeholder = "Select release type...",
showDownloadButton = true,
downloadedVersions = [],
}: ReleaseTypeSelectorProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const releaseOptions = [
...(availableReleaseTypes.stable
? [{ type: "stable" as const, version: availableReleaseTypes.stable }]
: []),
...(availableReleaseTypes.nightly && browser !== "chromium"
? [{ type: "nightly" as const, version: availableReleaseTypes.nightly }]
: []),
];
const selectedDisplayText = selectedReleaseType
? selectedReleaseType === "stable"
? "Stable"
: "Nightly"
: placeholder;
const selectedVersion =
selectedReleaseType === "stable"
? availableReleaseTypes.stable
: selectedReleaseType === "nightly"
? availableReleaseTypes.nightly
: null;
const isVersionDownloaded =
selectedVersion && downloadedVersions.includes(selectedVersion);
return (
<div className="space-y-4">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
className="justify-between w-full"
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandEmpty>No release types available.</CommandEmpty>
<CommandList>
<CommandGroup>
{releaseOptions.map((option) => {
const isDownloaded = downloadedVersions.includes(
option.version,
);
return (
<CommandItem
key={option.type}
value={option.type}
onSelect={(currentValue) => {
const selectedType = currentValue as
| "stable"
| "nightly";
onReleaseTypeSelect(
selectedType === selectedReleaseType
? null
: selectedType,
);
setPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedReleaseType === option.type
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex gap-2 items-center">
<span className="capitalize">{option.type}</span>
{option.type === "nightly" && (
<Badge variant="secondary" className="text-xs">
Nightly
</Badge>
)}
<Badge variant="outline" className="text-xs">
{option.version}
</Badge>
{isDownloaded && (
<Badge variant="default" className="text-xs">
Downloaded
</Badge>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showDownloadButton &&
selectedReleaseType &&
selectedVersion &&
!isVersionDownloaded && (
<LoadingButton
isLoading={isDownloading}
onClick={() => {
onDownload();
}}
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
</div>
);
}
+11 -23
View File
@@ -21,7 +21,7 @@ import {
} from "@/components/ui/select";
import { usePermissions } from "@/hooks/use-permissions";
import type { PermissionType } from "@/hooks/use-permissions";
import { showSuccessToast } from "@/lib/toast-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
@@ -31,7 +31,6 @@ interface AppSettings {
set_as_default_browser: boolean;
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
}
@@ -51,14 +50,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
@@ -210,11 +207,16 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
await invoke("clear_all_version_cache_and_refetch");
showSuccessToast("Cache cleared successfully", {
description:
"All browser version cache has been cleared and browsers are being refreshed",
"All browser version cache has been cleared and browsers are being refreshed.",
duration: 4000,
});
} catch (error) {
console.error("Failed to clear cache:", error);
showErrorToast("Failed to clear cache", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
} finally {
setIsClearingCache(false);
}
@@ -237,9 +239,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="h-4 w-4" />;
return <BsMic className="w-4 h-4" />;
case "camera":
return <BsCamera className="h-4 w-4" />;
return <BsCamera className="w-4 h-4" />;
}
};
@@ -255,7 +257,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const getStatusBadge = (isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="bg-green-100 text-green-800">
<Badge variant="default" className="text-green-800 bg-green-100">
Granted
</Badge>
);
@@ -286,7 +288,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme ||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled ||
settings.auto_delete_unused_binaries !==
originalSettings.auto_delete_unused_binaries;
@@ -361,19 +362,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<div className="space-y-4">
<Label className="text-base font-medium">Auto-Updates</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-updates"
checked={settings.auto_updates_enabled}
onCheckedChange={(checked) => {
updateSetting("auto_updates_enabled", checked as boolean);
}}
/>
<Label htmlFor="auto-updates" className="text-sm">
Enable automatic browser updates
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-delete-binaries"
@@ -436,7 +424,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
{permissions.map((permission) => (
<div
key={permission.permission_type}
className="flex items-center justify-between p-3 border rounded-lg"
className="flex justify-between items-center p-3 rounded-lg border"
>
<div className="flex items-center space-x-3">
{getPermissionIcon(permission.permission_type)}
-1
View File
@@ -103,7 +103,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const currentSystemTheme = await getNativeSystemTheme();
// Force re-evaluation by toggling the theme
const html = document.documentElement;
const currentClass = html.className;
// Apply the system theme class
if (currentSystemTheme === "dark") {
-1
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import React from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { LuDownload } from "react-icons/lu";
interface UpdateNotification {
id: string;
+2 -2
View File
@@ -24,11 +24,11 @@ import { ScrollArea } from "./ui/scroll-area";
interface GithubRelease {
tag_name: string;
assets: Array<{
assets: {
name: string;
browser_download_url: string;
hash?: string;
}>;
}[];
published_at: string;
is_nightly: boolean;
}
-129
View File
@@ -1,129 +0,0 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import {
LuCheckCheck,
LuCircleAlert,
LuClock,
LuRefreshCw,
} from "react-icons/lu";
export function VersionUpdateSettings() {
const {
isUpdating,
lastUpdateTime,
timeUntilNextUpdate,
updateProgress,
triggerManualUpdate,
formatTimeUntilUpdate,
formatLastUpdateTime,
} = useVersionUpdater();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LuRefreshCw className="h-5 w-5" />
Background Version Updates
</CardTitle>
<CardDescription>
Browser versions are automatically checked every 3 hours in the
background. New versions are cached and ready when you need them.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<LuClock className="h-4 w-4" />
Last Update
</div>
<div className="text-sm text-muted-foreground">
{formatLastUpdateTime(lastUpdateTime)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<LuCheckCheck className="h-4 w-4" />
Next Update
</div>
<div className="text-sm text-muted-foreground">
{timeUntilNextUpdate <= 0
? "Now"
: `In ${formatTimeUntilUpdate(timeUntilNextUpdate)}`}
</div>
</div>
</div>
{/* Progress indicator */}
{isUpdating && updateProgress && (
<Alert>
<LuRefreshCw className="h-4 w-4 animate-spin" />
<AlertTitle>Updating Browser Versions</AlertTitle>
<AlertDescription>
{updateProgress.current_browser ? (
<>
Checking {updateProgress.current_browser} (
{updateProgress.completed_browsers}/
{updateProgress.total_browsers})
<br />
{updateProgress.new_versions_found} new versions found so far
</>
) : (
"Starting version update..."
)}
</AlertDescription>
</Alert>
)}
{/* Manual update button */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="space-y-1">
<div className="text-sm font-medium">Manual Update</div>
<div className="text-xs text-muted-foreground">
Check for new browser versions now
</div>
</div>
<LoadingButton
isLoading={isUpdating}
onClick={() => {
void triggerManualUpdate();
}}
variant="outline"
size="sm"
disabled={isUpdating}
>
<LuRefreshCw className="h-4 w-4 mr-2" />
{isUpdating ? "Updating..." : "Check Now"}
</LoadingButton>
</div>
{/* Information about background updates */}
<Alert>
<LuCircleAlert className="h-4 w-4" />
<AlertTitle>How it works</AlertTitle>
<AlertDescription className="text-xs">
Version information is checked automatically every 3 hours
<br /> New versions are added to the cache without removing old
ones
<br /> When creating profiles or changing versions, you&apos;ll see
how many new versions were found
<br /> This keeps the app responsive while ensuring you have the
latest information
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function WindowDragArea() {
return (
<div
className="fixed top-0 right-0 left-0 z-50 h-8 cursor-move"
className="fixed top-0 right-0 left-0 h-10 z-9999"
style={{
// Ensure it's above all other content
zIndex: 9999,
+26 -128
View File
@@ -3,21 +3,19 @@ import {
dismissToast,
showDownloadToast,
showErrorToast,
showFetchingToast,
showSuccessToast,
} from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
interface GithubRelease {
tag_name: string;
assets: Array<{
assets: {
name: string;
browser_download_url: string;
hash?: string;
}>;
}[];
published_at: string;
is_nightly: boolean;
}
@@ -45,31 +43,6 @@ interface BrowserVersionsResult {
total_versions_count: number;
}
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string;
}
const isAlphaVersion = (version: string): boolean => {
// Check for common alpha/beta/dev indicators
const lowerVersion = version.toLowerCase();
return (
lowerVersion.includes("a") ||
lowerVersion.includes("b") ||
lowerVersion.includes("alpha") ||
lowerVersion.includes("beta") ||
lowerVersion.includes("dev") ||
lowerVersion.includes("rc") ||
lowerVersion.includes("pre") ||
// Check for patterns like "139.0b1" or "140.0a1"
/\d+\.\d+[ab]\d+/.test(lowerVersion)
);
};
export function useBrowserDownload() {
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
[],
@@ -78,7 +51,6 @@ export function useBrowserDownload() {
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(null);
const [isUpdatingVersions, setIsUpdatingVersions] = useState(false);
// Listen for download progress events
useEffect(() => {
@@ -88,53 +60,29 @@ export function useBrowserDownload() {
const browserName = getBrowserDisplayName(progress.browser);
// Check if this is an auto-update download to suppress completion toast
const checkAutoUpdate = async () => {
let isAutoUpdate = false;
try {
isAutoUpdate = await invoke<boolean>("is_auto_update_download", {
browser: progress.browser,
version: progress.version,
});
} catch (error) {
console.error("Failed to check auto-update status:", error);
}
// Show toast with progress
if (progress.stage === "downloading") {
const speedMBps = (
progress.speed_bytes_per_sec /
(1024 * 1024)
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
// Show toast with progress
if (progress.stage === "downloading") {
const speedMBps = (
progress.speed_bytes_per_sec /
(1024 * 1024)
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "completed") {
// Suppress completion toast for auto-updates
showDownloadToast(
browserName,
progress.version,
"completed",
undefined,
{
suppressCompletionToast: isAutoUpdate,
},
);
setDownloadProgress(null);
}
};
void checkAutoUpdate();
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "completed") {
showDownloadToast(browserName, progress.version, "completed");
setDownloadProgress(null);
}
});
return () => {
@@ -144,51 +92,6 @@ export function useBrowserDownload() {
};
}, []);
// Listen for version update progress events
useEffect(() => {
const unlisten = listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
if (progress.status === "updating") {
setIsUpdatingVersions(true);
if (progress.current_browser) {
const browserName = getBrowserDisplayName(progress.current_browser);
showFetchingToast(browserName, {
id: `version-update-${progress.current_browser}`,
description: "Fetching latest release information...",
});
}
} else if (progress.status === "completed") {
setIsUpdatingVersions(false);
if (progress.new_versions_found > 0) {
showSuccessToast(
`Found ${progress.new_versions_found} new browser versions!`,
{
duration: 3000,
},
);
}
// Dismiss any update toasts
toast.dismiss();
} else if (progress.status === "error") {
setIsUpdatingVersions(false);
showErrorToast("Failed to check for new versions", {
duration: 4000,
});
toast.dismiss();
}
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, []);
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
@@ -214,10 +117,8 @@ export function useBrowserDownload() {
const loadVersions = useCallback(async (browserStr: string) => {
const browserName = getBrowserDisplayName(browserStr);
// Show fetching toast
const toastId = showFetchingToast(browserName, {
id: `fetch-${browserStr}`,
});
// Use a simple loading state instead of toast for version fetching
console.log(`Fetching ${browserName} versions...`);
try {
const versionInfos = await invoke<BrowserVersionInfo[]>(
@@ -236,11 +137,9 @@ export function useBrowserDownload() {
);
setAvailableVersions(githubReleases);
dismissToast(toastId);
return githubReleases;
} catch (error) {
console.error("Failed to load versions:", error);
dismissToast(toastId);
showErrorToast(`Failed to fetch ${browserName} versions`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
@@ -374,7 +273,6 @@ export function useBrowserDownload() {
downloadedVersions,
isDownloading,
downloadProgress,
isUpdatingVersions,
loadVersions,
loadVersionsWithNewCount,
loadDownloadedVersions,
+103 -132
View File
@@ -1,9 +1,7 @@
import { UpdateNotificationComponent } from "@/components/update-notification";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { showToast } from "@/lib/toast-utils";
import { dismissToast, showToast } from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import React, { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { useCallback, useRef, useState } from "react";
interface UpdateNotification {
id: string;
@@ -23,73 +21,93 @@ export function useUpdateNotifications(
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
new Set(),
);
const [dismissedNotifications, setDismissedNotifications] = useState<
const [processedNotifications, setProcessedNotifications] = useState<
Set<string>
>(new Set());
const isUpdating = useCallback(
(browser: string) => updatingBrowsers.has(browser),
[updatingBrowsers],
);
// Add refs to track ongoing operations to prevent duplicates
const isCheckingForUpdates = useRef(false);
const activeDownloads = useRef<Set<string>>(new Set()); // Track "browser-version" keys
const checkForUpdates = useCallback(async () => {
// Prevent multiple simultaneous calls
if (isCheckingForUpdates.current) {
console.log("Already checking for updates, skipping duplicate call");
return;
}
isCheckingForUpdates.current = true;
try {
const updates = await invoke<UpdateNotification[]>(
"check_for_browser_updates",
);
// Filter out dismissed notifications unless they're for a newer version
const filteredUpdates = updates.filter((notification) => {
// Check if this exact notification was dismissed
if (dismissedNotifications.has(notification.id)) {
return false;
}
// Check if we dismissed an older version for this browser
const dismissedForBrowser = Array.from(dismissedNotifications).find(
(dismissedId) => {
const parts = dismissedId.split("_");
if (parts.length >= 2) {
const browser = parts[0];
return browser === notification.browser;
}
return false;
},
);
if (dismissedForBrowser) {
// Extract the dismissed version to compare
const dismissedParts = dismissedForBrowser.split("_to_");
if (dismissedParts.length === 2) {
const dismissedToVersion = dismissedParts[1];
// Only show if this is a newer version than what was dismissed
return notification.new_version !== dismissedToVersion;
}
}
return true;
// Filter out already processed notifications
const newUpdates = updates.filter((notification) => {
return !processedNotifications.has(notification.id);
});
setNotifications(filteredUpdates);
setNotifications(newUpdates);
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
// to avoid circular dependencies
// Automatically start downloads for new update notifications
for (const notification of newUpdates) {
if (!processedNotifications.has(notification.id)) {
setProcessedNotifications((prev) =>
new Set(prev).add(notification.id),
);
// Start automatic update without user interaction
void handleAutoUpdate(
notification.browser,
notification.new_version,
notification.id,
);
}
}
} catch (error) {
console.error("Failed to check for updates:", error);
} finally {
isCheckingForUpdates.current = false;
}
}, [dismissedNotifications]);
}, [processedNotifications]);
const handleAutoUpdate = useCallback(
async (browser: string, newVersion: string, notificationId: string) => {
const downloadKey = `${browser}-${newVersion}`;
// Check if this download is already in progress
if (activeDownloads.current.has(downloadKey)) {
console.log(
`Download already in progress for ${downloadKey}, skipping duplicate`,
);
return;
}
// Mark download as active and disable browser
activeDownloads.current.add(downloadKey);
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
const handleUpdate = useCallback(
async (browser: string, newVersion: string) => {
try {
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
const browserDisplayName = getBrowserDisplayName(browser);
// Dismiss all notifications for this browser first
const browserNotifications = notifications.filter(
(n) => n.browser === browser,
);
for (const notification of browserNotifications) {
toast.dismiss(notification.id);
await invoke("dismiss_update_notification", {
notificationId: notification.id,
});
}
// Dismiss the notification in the backend
await invoke("dismiss_update_notification", {
notificationId,
});
// Show update started notification
showToast({
id: `auto-update-started-${browser}-${newVersion}`,
type: "loading",
title: `${browserDisplayName} update started`,
description: `Version ${newVersion} download will begin shortly. Browser launch is disabled until update completes.`,
duration: 4000,
});
try {
// Check if browser already exists before downloading
@@ -103,14 +121,25 @@ export function useUpdateNotifications(
console.log(
`${browserDisplayName} ${newVersion} already exists, skipping download`,
);
showToast({
id: `auto-update-skip-download-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} ${newVersion} already available`,
description: "Updating profile configurations...",
duration: 3000,
});
} else {
// Mark download as auto-update in the backend for toast suppression
await invoke("mark_auto_update_download", {
browser,
version: newVersion,
// Show download starting notification
showToast({
id: `auto-update-download-starting-${browser}-${newVersion}`,
type: "loading",
title: `Starting ${browserDisplayName} ${newVersion} download`,
description: "Download progress will be shown below...",
duration: 4000,
});
// Download the browser (progress will be handled by use-browser-download hook)
// Download the browser - this will trigger download progress events automatically
await invoke("download_browser", {
browserStr: browser,
version: newVersion,
@@ -134,59 +163,45 @@ export function useUpdateNotifications(
: `${updatedProfiles.length} profiles have been updated`;
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`,
duration: 5000,
description: `${profileText} to version ${newVersion}. You can now launch your browsers with the latest version.`,
duration: 6000,
});
} else {
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update ready`,
description:
"All affected profiles are currently running. Stop them and manually update their versions to use the new version.",
duration: 5000,
title: `${browserDisplayName} update completed`,
description: `Version ${newVersion} is now available. Running profiles will use the new version when restarted.`,
duration: 6000,
});
}
// Trigger profile refresh to update UI with new versions
if (onProfilesUpdated) {
void onProfilesUpdated();
await onProfilesUpdated();
}
} catch (downloadError) {
console.error("Failed to download browser:", downloadError);
// Clean up auto-update tracking on error
try {
await invoke("remove_auto_update_download", {
browser,
version: newVersion,
});
} catch (e) {
console.error("Failed to clean up auto-update tracking:", e);
}
dismissToast(`download-${browser}-${newVersion}`);
showToast({
id: `auto-update-error-${browser}-${newVersion}`,
type: "error",
title: `Failed to download ${browserDisplayName} ${newVersion}`,
description: String(downloadError),
duration: 6000,
duration: 8000,
});
throw downloadError;
}
// Refresh notifications to clear any remaining ones
await checkForUpdates();
} catch (error) {
console.error("Failed to start update:", error);
const browserDisplayName = getBrowserDisplayName(browser);
showToast({
type: "error",
title: `Failed to update ${browserDisplayName}`,
description: String(error),
duration: 6000,
});
console.error("Failed to start auto-update:", error);
throw error;
} finally {
// Clean up
activeDownloads.current.delete(downloadKey);
setUpdatingBrowsers((prev) => {
const next = new Set(prev);
next.delete(browser);
@@ -194,56 +209,12 @@ export function useUpdateNotifications(
});
}
},
[notifications, checkForUpdates, onProfilesUpdated],
[onProfilesUpdated],
);
const handleDismiss = useCallback(
async (notificationId: string) => {
try {
toast.dismiss(notificationId);
await invoke("dismiss_update_notification", { notificationId });
// Track this notification as dismissed to prevent showing it again
setDismissedNotifications((prev) => new Set(prev).add(notificationId));
await checkForUpdates();
} catch (error) {
console.error("Failed to dismiss notification:", error);
}
},
[checkForUpdates],
);
// Separate effect to show toasts when notifications change
useEffect(() => {
for (const notification of notifications) {
const isUpdating = updatingBrowsers.has(notification.browser);
toast.custom(
() => (
<UpdateNotificationComponent
notification={notification}
onUpdate={handleUpdate}
onDismiss={handleDismiss}
isUpdating={isUpdating}
/>
),
{
id: notification.id,
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
style: {
zIndex: 99999, // Ensure notifications appear above dialogs
pointerEvents: "auto", // Ensure notifications remain interactive
},
},
);
}
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
return {
notifications,
isUpdating,
checkForUpdates,
isUpdating: (browser: string) => updatingBrowsers.has(browser),
};
}
+162 -65
View File
@@ -1,13 +1,14 @@
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showLoadingToast,
showVersionUpdateToast,
showAutoUpdateToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
interface VersionUpdateProgress {
current_browser: string;
@@ -32,6 +33,13 @@ interface BrowserVersionsResult {
total_versions_count: number;
}
interface AutoUpdateEvent {
browser: string;
new_version: string;
notification_id: string;
affected_profiles: string[];
}
export function useVersionUpdater() {
const [isUpdating, setIsUpdating] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState<number | null>(null);
@@ -39,6 +47,18 @@ export function useVersionUpdater() {
const [updateProgress, setUpdateProgress] =
useState<VersionUpdateProgress | null>(null);
const loadUpdateStatus = useCallback(async () => {
try {
const [lastUpdate, timeUntilNext] = await invoke<[number | null, number]>(
"get_version_update_status",
);
setLastUpdateTime(lastUpdate);
setTimeUntilNextUpdate(timeUntilNext);
} catch (error) {
console.error("Failed to load version update status:", error);
}
}, []);
// Listen for version update progress events
useEffect(() => {
const unlisten = listen<VersionUpdateProgress>(
@@ -50,42 +70,35 @@ export function useVersionUpdater() {
if (progress.status === "updating") {
setIsUpdating(true);
if (progress.current_browser) {
const browserName = getBrowserDisplayName(progress.current_browser);
showVersionUpdateToast(
`Downloading release information for ${browserName}`,
{
id: "version-update-progress",
progress: {
current: progress.completed_browsers + 1,
total: progress.total_browsers,
found: progress.new_versions_found,
},
},
);
} else {
showLoadingToast("Starting version update check...", {
id: "version-update-progress",
description: "Initializing browser version check...",
});
}
// 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,
},
});
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
toast.success(
`Found ${progress.new_versions_found} new browser versions!`,
{
id: "version-update-progress",
duration: 4000,
description:
"Version information has been updated in the background",
},
);
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
toast.success("No new browser versions found", {
id: "version-update-progress",
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
@@ -96,16 +109,115 @@ export function useVersionUpdater() {
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
toast.error("Failed to update browser versions", {
id: "version-update-progress",
duration: 4000,
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, [loadUpdateStatus]);
// Listen for browser auto-update events
useEffect(() => {
const unlisten = listen<AutoUpdateEvent>(
"browser-auto-update-available",
(event) => {
const handleAutoUpdate = async () => {
const { browser, new_version, notification_id } = event.payload;
console.log("Browser auto-update event received:", event.payload);
const browserDisplayName = getBrowserDisplayName(browser);
try {
// Show auto-update start notification
showAutoUpdateToast(browserDisplayName, new_version, {
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
});
// Dismiss the update notification in the backend
await invoke("dismiss_update_notification", {
notificationId: notification_id,
});
// Check if browser already exists before downloading
const isDownloaded = await invoke<boolean>("check_browser_exists", {
browserStr: browser,
version: new_version,
});
if (isDownloaded) {
// Browser already exists, skip download and go straight to profile update
console.log(
`${browserDisplayName} ${new_version} already exists, skipping download`,
);
showSuccessToast(
`${browserDisplayName} ${new_version} already available`,
{
description: "Updating profile configurations...",
duration: 3000,
},
);
} else {
// Download the browser - this will trigger download progress events automatically
await invoke("download_browser", {
browserStr: browser,
version: new_version,
});
}
// Complete the update with auto-update of profile versions
const updatedProfiles = await invoke<string[]>(
"complete_browser_update_with_auto_update",
{
browser,
newVersion: new_version,
},
);
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
showSuccessToast(`${browserDisplayName} update completed`, {
description: `${profileText} to version ${new_version}. You can now launch your browsers with the latest version.`,
duration: 6000,
});
} else {
showSuccessToast(`${browserDisplayName} update completed`, {
description: `Version ${new_version} is now available. Running profiles will use the new version when restarted.`,
duration: 6000,
});
}
} catch (error) {
console.error("Failed to handle browser auto-update:", error);
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
description:
error instanceof Error
? error.message
: "Unknown error occurred",
duration: 8000,
});
}
};
// Call the async handler
void handleAutoUpdate();
},
);
return () => {
void unlisten.then((fn) => {
fn();
@@ -125,19 +237,7 @@ export function useVersionUpdater() {
return () => {
clearInterval(interval);
};
}, []);
const loadUpdateStatus = useCallback(async () => {
try {
const [lastUpdate, timeUntilNext] = await invoke<[number | null, number]>(
"get_version_update_status",
);
setLastUpdateTime(lastUpdate);
setTimeUntilNextUpdate(timeUntilNext);
} catch (error) {
console.error("Failed to load version update status:", error);
}
}, []);
}, [loadUpdateStatus]);
const triggerManualUpdate = useCallback(async () => {
try {
@@ -158,17 +258,17 @@ export function useVersionUpdater() {
).length;
if (failedUpdates > 0) {
toast.warning("Update completed with some errors", {
showErrorToast("Update completed with some errors", {
description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`,
duration: 5000,
});
} else if (totalNewVersions > 0) {
toast.success(`Found ${totalNewVersions} new browser versions!`, {
description: `Updated ${successfulUpdates} browsers successfully`,
showSuccessToast("Browser versions updated successfully", {
description: `Found ${totalNewVersions} new versions across ${successfulUpdates} browsers. Auto-downloads will start shortly.`,
duration: 4000,
});
} else {
toast.success("No new browser versions found", {
showSuccessToast("No new browser versions found", {
description: "All browser versions are up to date",
duration: 3000,
});
@@ -178,7 +278,7 @@ export function useVersionUpdater() {
return results;
} catch (error) {
console.error("Failed to trigger manual update:", error);
toast.error("Failed to update browser versions", {
showErrorToast("Failed to update browser versions", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
@@ -200,7 +300,7 @@ export function useVersionUpdater() {
// Show notification about new versions if any were found
if (result.new_versions_count && result.new_versions_count > 0) {
const browserName = getBrowserDisplayName(browserStr);
toast.success(
showSuccessToast(
`Found ${result.new_versions_count} new ${browserName} versions!`,
{
duration: 3000,
@@ -219,18 +319,15 @@ export function useVersionUpdater() {
);
const formatTimeUntilUpdate = useCallback((seconds: number): string => {
if (seconds <= 0) return "Update overdue";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
if (seconds < 60) {
return `${seconds} seconds`;
}
if (minutes > 0) {
return `${minutes}m`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
return "< 1m";
const hours = Math.floor(minutes / 60);
return `${hours} hour${hours === 1 ? "" : "s"}`;
}, []);
const formatLastUpdateTime = useCallback(
+2 -1
View File
@@ -3,6 +3,7 @@
* Centralized helpers for browser name mapping, icons, etc.
*/
import { ZenBrowser } from "@/components/icons/zen-browser";
import { FaChrome, FaFirefox } from "react-icons/fa";
import { SiBrave, SiMullvad, SiTorbrowser } from "react-icons/si";
@@ -38,7 +39,7 @@ export function getBrowserIcon(browserType: string) {
case "firefox-developer":
return FaFirefox;
case "zen":
return FaFirefox;
return ZenBrowser;
case "tor-browser":
return SiTorbrowser;
default:
+47 -1
View File
@@ -43,6 +43,7 @@ export interface VersionUpdateToastProps extends BaseToastProps {
current: number;
total: number;
found: number;
current_browser?: string;
};
}
@@ -134,7 +135,7 @@ export function showToast(props: ToastProps & { id?: string }) {
},
});
} else {
sonnerToast.custom((id) => React.createElement(UnifiedToast, props), {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
style: {
@@ -214,6 +215,7 @@ export function showVersionUpdateToast(
current: number;
total: number;
found: number;
current_browser?: string;
};
duration?: number;
},
@@ -292,6 +294,26 @@ export function showTwilightUpdateToast(
});
}
export function showAutoUpdateToast(
browserName: string,
version: string,
options?: {
id?: string;
description?: string;
duration?: number;
},
) {
return showToast({
type: "loading",
title: `${browserName} update started`,
description:
options?.description ??
`Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`,
id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`,
duration: options?.duration ?? 4000,
});
}
// Generic helper for dismissing toasts
export function dismissToast(id: string) {
sonnerToast.dismiss(id);
@@ -301,3 +323,27 @@ export function dismissToast(id: string) {
export function dismissAllToasts() {
sonnerToast.dismiss();
}
// Add a specific function for unified version update progress
export function showUnifiedVersionUpdateToast(
title: string,
options?: {
id?: string;
description?: string;
progress?: {
current: number;
total: number;
found: number;
current_browser?: string;
};
duration?: number;
},
) {
return showToast({
type: "version-update",
title,
id: "unified-version-update",
duration: Number.POSITIVE_INFINITY, // Keep showing until completed
...options,
});
}
+8
View File
@@ -3,6 +3,8 @@ export interface ProxySettings {
proxy_type: string; // "http", "https", "socks4", or "socks5"
host: string;
port: number;
username?: string;
password?: string;
}
export interface TableSortingSettings {
@@ -18,6 +20,7 @@ export interface BrowserProfile {
proxy?: ProxySettings;
process_id?: number;
last_launch?: number;
release_type: string; // "stable" or "nightly"
}
export interface DetectedProfile {
@@ -27,6 +30,11 @@ export interface DetectedProfile {
description: string;
}
export interface BrowserReleaseTypes {
stable?: string;
nightly?: string;
}
export interface AppUpdateInfo {
current_version: string;
new_version: string;