Compare commits

...

82 Commits

Author SHA1 Message Date
zhom c46f54536b chore: linting 2025-06-06 04:52:05 +04:00
zhom 6cbc8627a1 chore: version bump 2025-06-06 04:43:22 +04:00
zhom a4f4cc2f27 style: clean up settings dialog 2025-06-06 04:41:50 +04:00
zhom 21c4d0a8ab build: use custom dependabot token for dependency automerge 2025-06-06 04:29:18 +04:00
zhom 9335149153 Merge pull request #12 from zhom/dependabot/cargo/src-tauri/rust-dependencies-ac9a5aa5e0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 6 updates
2025-06-06 04:08:04 +04:00
zhom 6711659231 Merge pull request #13 from zhom/dependabot/npm_and_yarn/frontend-dependencies-1eccf95907
deps(deps): bump @types/node from 22.15.29 to 22.15.30 in the frontend-dependencies group
2025-06-06 04:07:37 +04:00
zhom 8a592e3d7d Merge pull request #14 from zhom/dependabot/npm_and_yarn/nodecar/nodecar-dependencies-1eccf95907
deps(nodecar)(deps): bump @types/node from 22.15.29 to 22.15.30 in /nodecar in the nodecar-dependencies group
2025-06-06 04:07:11 +04:00
zhom beea23307b build: install xdg-utils on ubuntu arm 2025-06-06 03:34:46 +04:00
zhom 19b66d006d build: switch to ubuntu-22.04-arm for linux arm build 2025-06-06 03:12:24 +04:00
zhom c7a36f6cd0 build: try to update mirrors if security.ubuntu.com is down 2025-06-06 02:51:39 +04:00
zhom 7404cb3ff8 build: install arm64 libraries on linux arm build worker 2025-06-06 02:12:52 +04:00
zhom ee91445fe1 feat: add ability to import existing profiles 2025-06-06 02:12:21 +04:00
zhom 77d53c7f32 build: install pkg-config on ubuntu 2025-06-06 01:28:34 +04:00
zhom a21f22a916 build: remove invalid package installation 2025-06-06 01:09:34 +04:00
dependabot[bot] 4aaf2eecbc deps(nodecar)(deps): bump @types/node
Bumps the nodecar-dependencies group in /nodecar with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:02:39 +00:00
dependabot[bot] f750e64b81 deps(deps): bump @types/node in the frontend-dependencies group
Bumps the frontend-dependencies group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:00:09 +00:00
dependabot[bot] 16fd3e3c5e deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.35.1` | `0.35.2` |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.17.0` | `3.18.0` |
| [hyper-util](https://github.com/hyperium/hyper-util) | `0.1.13` | `0.1.14` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.5` | `0.6.6` |
| [windows-registry](https://github.com/microsoft/windows-rs) | `0.4.0` | `0.5.2` |
| [windows-strings](https://github.com/microsoft/windows-rs) | `0.3.1` | `0.4.2` |


Updates `sysinfo` from 0.35.1 to 0.35.2
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.35.1...v0.35.2)

Updates `bumpalo` from 3.17.0 to 3.18.0
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/3.17.0...v3.18.0)

Updates `hyper-util` from 0.1.13 to 0.1.14
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.13...v0.1.14)

Updates `tower-http` from 0.6.5 to 0.6.6
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.5...tower-http-0.6.6)

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

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

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.18.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper-util
  dependency-version: 0.1.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows-registry
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: windows-strings
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:00:03 +00:00
zhom 93eae1d77f build: set pkg-config variables for linux arm 2025-06-06 00:57:21 +04:00
zhom 82615c24bd build: set cross-compile build variables for linux arm 2025-06-06 00:39:05 +04:00
zhom 0769106a51 build: run dependabot on saturday 2025-06-06 00:32:56 +04:00
zhom 18aa3cb87b test: fix 2025-06-06 00:22:01 +04:00
zhom f066105c0f build: remove universal macos build until @yao-pkg/pkg has support for universal macos binaries 2025-06-06 00:10:20 +04:00
zhom 9d8b3629f6 docs: readme 2025-06-06 00:04:38 +04:00
zhom 353e149886 build: switch to ubuntu 22 for linux build 2025-06-05 23:59:44 +04:00
zhom 2258edbb18 test: fix 2025-06-05 23:45:03 +04:00
zhom 69fff99bbe docs: readme 2025-06-05 23:38:16 +04:00
zhom a277b7d8a2 build: fix script path 2025-06-05 23:35:38 +04:00
zhom 7af3f7e86a docs: update readme 2025-06-05 23:34:48 +04:00
zhom de9c47241a build: cargo audit && copy binary instead of moving 2025-06-05 23:25:43 +04:00
zhom 5747729e30 build: use cargo audit for rust linting 2025-06-05 23:12:08 +04:00
zhom f71bda0fbf build: don't set extra build variables on linux arm systems 2025-06-05 22:59:51 +04:00
zhom 67bfb17e5a fix: correctly mark tor browser alpha versions: 2025-06-05 22:11:57 +04:00
zhom bb2eb65c1e chore: linter 2025-06-05 21:26:47 +04:00
zhom f397568785 docs: update readme to include linux 2025-06-05 21:17:10 +04:00
zhom 0da34f04cb feat: linux support preview 2025-06-05 21:15:05 +04:00
zhom 6836d73ffa refactor: prepare the backend for cross-platform function 2025-06-03 23:37:31 +04:00
zhom 63b890d47f docs: add responsible desclosure guidelines 2025-06-03 17:01:47 +04:00
zhom aeb6a08fc8 build: automatically merge dependendabot changes if they pass ci 2025-06-03 16:54:14 +04:00
zhom c698fff101 build: manage nodecar dependencies via workspace 2025-06-03 16:53:27 +04:00
zhom ccfd1f81f6 build: fail build if security scan fails 2025-06-03 16:30:34 +04:00
zhom 4c42099661 fix: osv permissions 2025-06-03 15:33:45 +04:00
zhom 4c4aa10d8c fix: reset lock files after bad dependabot merge 2025-06-03 15:33:03 +04:00
zhom a1a6ef63e4 Merge pull request #11 from zhom/dependabot/npm_and_yarn/frontend-dependencies-852ce57221
deps(deps): bump the frontend-dependencies group across 1 directory with 12 updates
2025-06-03 15:26:21 +04:00
dependabot[bot] bd7b9f1d9f deps(deps): bump the frontend-dependencies group across 1 directory with 12 updates
Bumps the frontend-dependencies group with 1 update in the / directory: [@jridgewell/trace-mapping](https://github.com/jridgewell/trace-mapping).


Updates `@jridgewell/trace-mapping` from 0.3.9 to 0.3.25
- [Release notes](https://github.com/jridgewell/trace-mapping/releases)
- [Commits](https://github.com/jridgewell/trace-mapping/compare/v0.3.9...v0.3.25)

Updates `ansi-regex` from 5.0.1 to 6.1.0
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.1...v6.1.0)

Updates `chownr` from 1.1.4 to 3.0.0
- [Commits](https://github.com/isaacs/chownr/compare/v1.1.4...v3.0.0)

Updates `emoji-regex` from 8.0.0 to 9.2.2
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v8.0.0...v9.2.2)

Updates `has-flag` from 3.0.0 to 4.0.0
- [Release notes](https://github.com/sindresorhus/has-flag/releases)
- [Commits](https://github.com/sindresorhus/has-flag/compare/v3.0.0...v4.0.0)

Updates `is-fullwidth-code-point` from 3.0.0 to 4.0.0
- [Release notes](https://github.com/sindresorhus/is-fullwidth-code-point/releases)
- [Commits](https://github.com/sindresorhus/is-fullwidth-code-point/compare/v3.0.0...v4.0.0)

Updates `isarray` from 1.0.0 to 2.0.5
- [Release notes](https://github.com/juliangruber/isarray/releases)
- [Commits](https://github.com/juliangruber/isarray/compare/v1.0.0...v2.0.5)

Updates `string-width` from 4.2.3 to 7.2.0
- [Release notes](https://github.com/sindresorhus/string-width/releases)
- [Commits](https://github.com/sindresorhus/string-width/compare/v4.2.3...v7.2.0)

Updates `strip-ansi` from 6.0.1 to 7.1.0
- [Release notes](https://github.com/chalk/strip-ansi/releases)
- [Commits](https://github.com/chalk/strip-ansi/compare/v6.0.1...v7.1.0)

Updates `strip-json-comments` from 2.0.1 to 3.1.1
- [Release notes](https://github.com/sindresorhus/strip-json-comments/releases)
- [Commits](https://github.com/sindresorhus/strip-json-comments/compare/v2.0.1...v3.1.1)

Updates `supports-color` from 5.5.0 to 7.2.0
- [Release notes](https://github.com/chalk/supports-color/releases)
- [Commits](https://github.com/chalk/supports-color/compare/v5.5.0...v7.2.0)

Updates `wrap-ansi` from 7.0.0 to 9.0.0
- [Release notes](https://github.com/chalk/wrap-ansi/releases)
- [Commits](https://github.com/chalk/wrap-ansi/compare/v7.0.0...v9.0.0)

---
updated-dependencies:
- dependency-name: "@jridgewell/trace-mapping"
  dependency-version: 0.3.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: ansi-regex
  dependency-version: 6.1.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: chownr
  dependency-version: 3.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: emoji-regex
  dependency-version: 9.2.2
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: has-flag
  dependency-version: 4.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: is-fullwidth-code-point
  dependency-version: 4.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: isarray
  dependency-version: 2.0.5
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: string-width
  dependency-version: 7.2.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: strip-ansi
  dependency-version: 7.1.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: strip-json-comments
  dependency-version: 3.1.1
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: supports-color
  dependency-version: 7.2.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: wrap-ansi
  dependency-version: 9.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 11:24:36 +00:00
zhom f93b5daa9b Merge pull request #8 from zhom/dependabot/npm_and_yarn/nodecar/nodecar-dependencies-a69e916d1e
deps(nodecar)(deps): bump the nodecar-dependencies group in /nodecar with 4 updates
2025-06-03 15:10:21 +04:00
zhom f7f45bdc90 Merge pull request #10 from zhom/dependabot/cargo/src-tauri/rust-dependencies-04ecd6b2d6
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 3 updates
2025-06-03 15:09:36 +04:00
dependabot[bot] 48067ee3a7 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 3 updates: [reqwest](https://github.com/seanmonstar/reqwest), [camino](https://github.com/camino-rs/camino) and [tower-http](https://github.com/tower-rs/tower-http).


Updates `reqwest` from 0.12.18 to 0.12.19
- [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.18...v0.12.19)

Updates `camino` from 1.1.9 to 1.1.10
- [Release notes](https://github.com/camino-rs/camino/releases)
- [Changelog](https://github.com/camino-rs/camino/blob/main/CHANGELOG.md)
- [Commits](https://github.com/camino-rs/camino/compare/camino-1.1.9...camino-1.1.10)

Updates `tower-http` from 0.6.4 to 0.6.5
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.4...tower-http-0.6.5)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: camino
  dependency-version: 1.1.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 11:07:37 +00:00
zhom 87bd75aa21 chore: pnpm update && pnpm install --ignore-workspace 2025-06-03 15:07:13 +04:00
zhom cf443061b6 chore: pnpm install 2025-06-03 15:02:46 +04:00
zhom ca662d91a1 Merge pull request #7 from zhom/dependabot/github_actions/github-actions-0c45e47a22
ci(deps): bump google/osv-scanner-action from 1.7.1 to 2.0.2 in the github-actions group
2025-06-03 14:58:38 +04:00
dependabot[bot] 2963dbc0f9 deps(nodecar)(deps): bump the nodecar-dependencies group
Bumps the nodecar-dependencies group in /nodecar with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@yao-pkg/pkg](https://github.com/yao-pkg/pkg), [commander](https://github.com/tj/commander.js) and [proxy-chain](https://github.com/apify/proxy-chain).


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

Updates `@yao-pkg/pkg` from 6.4.1 to 6.5.1
- [Release notes](https://github.com/yao-pkg/pkg/releases)
- [Changelog](https://github.com/yao-pkg/pkg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yao-pkg/pkg/compare/v6.4.1...v6.5.1)

Updates `commander` from 13.1.0 to 14.0.0
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tj/commander.js/compare/v13.1.0...v14.0.0)

Updates `proxy-chain` from 2.5.8 to 2.5.9
- [Release notes](https://github.com/apify/proxy-chain/releases)
- [Changelog](https://github.com/apify/proxy-chain/blob/master/CHANGELOG.md)
- [Commits](https://github.com/apify/proxy-chain/compare/v2.5.8...v2.5.9)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
- dependency-name: "@yao-pkg/pkg"
  dependency-version: 6.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nodecar-dependencies
- dependency-name: commander
  dependency-version: 14.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nodecar-dependencies
- dependency-name: proxy-chain
  dependency-version: 2.5.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 10:51:44 +00:00
dependabot[bot] 225ed05d08 ci(deps): bump google/osv-scanner-action in the github-actions group
Bumps the github-actions group with 1 update: [google/osv-scanner-action](https://github.com/google/osv-scanner-action).


Updates `google/osv-scanner-action` from 1.7.1 to 2.0.2
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/1f1242919d8a60496dd1874b24b62b2370ed4c78...e69cc6c86b31f1e7e23935bbe7031b50e51082de)

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 10:48:03 +00:00
zhom 97de246ac6 feat: use osv, add pr checks, extend dependabot 2025-06-03 14:46:58 +04:00
zhom b00f62ebec fix: improve toast and dialog interations 2025-06-03 13:56:58 +04:00
zhom 2025a2a690 feat: better integrate with macos titlebar 2025-06-03 13:11:17 +04:00
zhom 2f1faa02e4 chore: version bump 2025-06-02 18:30:50 +04:00
zhom 7a5b807828 feat: select running profile if one available for opened urls 2025-06-02 13:41:38 +04:00
zhom d0a5c16ce9 chore: don't try to auto-approve dependabot pr 2025-06-02 13:12:35 +04:00
zhom e2e1ad1582 Merge pull request #5 from zhom/dependabot/npm_and_yarn/all-f53a2d5633
build(deps): bump the all group with 109 updates
2025-06-02 13:09:29 +04:00
dependabot[bot] cb61861503 build(deps): bump the all group with 109 updates
Bumps the all group with 109 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `15.3.2` | `15.3.3` |
| [sonner](https://github.com/emilkowalski/sonner) | `2.0.3` | `2.0.4` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.27.0` | `9.28.0` |
| [@next/eslint-plugin-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-plugin-next) | `15.3.2` | `15.3.3` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.7` | `4.1.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.15.21` | `22.15.29` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.5` | `19.1.6` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.32.1` | `8.33.0` |
| [eslint](https://github.com/eslint/eslint) | `9.27.0` | `9.28.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `15.3.2` | `15.3.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `15.5.2` | `16.1.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.7` | `4.1.8` |
| [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) | `1.3.0` | `1.3.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.32.1` | `8.33.0` |
| [@babel/compat-data](https://github.com/babel/babel/tree/HEAD/packages/babel-compat-data) | `7.27.2` | `7.27.3` |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.27.1` | `7.27.4` |
| [@babel/generator](https://github.com/babel/babel/tree/HEAD/packages/babel-generator) | `7.27.1` | `7.27.3` |
| [@babel/helper-module-transforms](https://github.com/babel/babel/tree/HEAD/packages/babel-helper-module-transforms) | `7.27.1` | `7.27.3` |
| [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) | `7.27.1` | `7.27.4` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `7.27.2` | `7.27.4` |
| [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) | `7.27.1` | `7.27.4` |
| [@babel/types](https://github.com/babel/babel/tree/HEAD/packages/babel-types) | `7.27.1` | `7.27.3` |
| [@esbuild/aix-ppc64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/android-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/android-arm](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/android-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/darwin-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/darwin-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/freebsd-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/freebsd-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-arm](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-ia32](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-loong64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-mips64el](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-ppc64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-riscv64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-s390x](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/linux-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/netbsd-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/netbsd-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/openbsd-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/openbsd-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/sunos-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/win32-arm64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/win32-ia32](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@esbuild/win32-x64](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [@next/env](https://github.com/vercel/next.js/tree/HEAD/packages/next-env) | `15.3.2` | `15.3.3` |
| [@next/swc-darwin-arm64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-arm64) | `15.3.2` | `15.3.3` |
| [@next/swc-darwin-x64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-x64) | `15.3.2` | `15.3.3` |
| [@next/swc-linux-arm64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-gnu) | `15.3.2` | `15.3.3` |
| [@next/swc-linux-arm64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-musl) | `15.3.2` | `15.3.3` |
| [@next/swc-linux-x64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-gnu) | `15.3.2` | `15.3.3` |
| [@next/swc-linux-x64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-musl) | `15.3.2` | `15.3.3` |
| [@next/swc-win32-arm64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-arm64-msvc) | `15.3.2` | `15.3.3` |
| [@next/swc-win32-x64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-x64-msvc) | `15.3.2` | `15.3.3` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-powerpc64le-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [@tailwindcss/node](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-node) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-android-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/android-arm64) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-darwin-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-arm64) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-darwin-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-x64) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-freebsd-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/freebsd-x64) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-linux-arm-gnueabihf](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm-gnueabihf) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-linux-arm64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-gnu) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-linux-arm64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-musl) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-linux-x64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-gnu) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-linux-x64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-musl) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-wasm32-wasi](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-win32-arm64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-arm64-msvc) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide-win32-x64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-x64-msvc) | `4.1.7` | `4.1.8` |
| [@tailwindcss/oxide](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.1.7` | `4.1.8` |
| [@typescript-eslint/scope-manager](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/scope-manager) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/type-utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/type-utils) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/types](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/types) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-estree) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.32.1` | `8.33.0` |
| [@typescript-eslint/visitor-keys](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/visitor-keys) | `8.32.1` | `8.33.0` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.24.5` | `4.25.0` |
| [caniuse-lite](https://github.com/browserslist/caniuse-lite) | `1.0.30001718` | `1.0.30001720` |
| [commander](https://github.com/tj/commander.js) | `13.1.0` | `14.0.0` |
| [electron-to-chromium](https://github.com/kilian/electron-to-chromium) | `1.5.157` | `1.5.161` |
| [es-abstract](https://github.com/ljharb/es-abstract) | `1.23.10` | `1.24.0` |
| [esbuild](https://github.com/evanw/esbuild) | `0.25.4` | `0.25.5` |
| [fdir](https://github.com/thecodrr/fdir) | `6.4.4` | `6.4.5` |
| [onetime](https://github.com/sindresorhus/onetime) | `6.0.0` | `7.0.0` |
| [react-remove-scroll](https://github.com/theKashey/react-remove-scroll) | `2.7.0` | `2.7.1` |
| [rollup](https://github.com/rollup/rollup) | `4.41.0` | `4.41.1` |
| [tinyglobby](https://github.com/SuperchupuDev/tinyglobby) | `0.2.13` | `0.2.14` |
| [unrs-resolver](https://github.com/unrs/unrs-resolver) | `1.7.2` | `1.7.8` |


Updates `next` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.3.2...v15.3.3)

Updates `sonner` from 2.0.3 to 2.0.4
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/compare/v2.0.3...v2.0.4)

Updates `@eslint/js` from 9.27.0 to 9.28.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.28.0/packages/js)

Updates `@next/eslint-plugin-next` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/packages/eslint-plugin-next)

Updates `@tailwindcss/postcss` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/packages/@tailwindcss-postcss)

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

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

Updates `@typescript-eslint/eslint-plugin` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/parser)

Updates `eslint` from 9.27.0 to 9.28.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.27.0...v9.28.0)

Updates `eslint-config-next` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/packages/eslint-config-next)

Updates `lint-staged` from 15.5.2 to 16.1.0
- [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/v15.5.2...v16.1.0)

Updates `tailwindcss` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/packages/tailwindcss)

Updates `tw-animate-css` from 1.3.0 to 1.3.3
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.0...v1.3.3)

Updates `typescript-eslint` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/typescript-eslint)

Updates `@babel/compat-data` from 7.27.2 to 7.27.3
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.3/packages/babel-compat-data)

Updates `@babel/core` from 7.27.1 to 7.27.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.4/packages/babel-core)

Updates `@babel/generator` from 7.27.1 to 7.27.3
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.3/packages/babel-generator)

Updates `@babel/helper-module-transforms` from 7.27.1 to 7.27.3
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.3/packages/babel-helper-module-transforms)

Updates `@babel/helpers` from 7.27.1 to 7.27.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.4/packages/babel-helpers)

Updates `@babel/parser` from 7.27.2 to 7.27.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.4/packages/babel-parser)

Updates `@babel/traverse` from 7.27.1 to 7.27.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.4/packages/babel-traverse)

Updates `@babel/types` from 7.27.1 to 7.27.3
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.3/packages/babel-types)

Updates `@esbuild/aix-ppc64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/android-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/android-arm` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/android-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/darwin-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/darwin-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/freebsd-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/freebsd-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-arm` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-ia32` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-loong64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-mips64el` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-ppc64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-riscv64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-s390x` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/linux-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/netbsd-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/netbsd-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/openbsd-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/openbsd-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/sunos-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/win32-arm64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/win32-ia32` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@esbuild/win32-x64` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `@next/env` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/packages/next-env)

Updates `@next/swc-darwin-arm64` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/darwin-arm64)

Updates `@next/swc-darwin-x64` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/darwin-x64)

Updates `@next/swc-linux-arm64-gnu` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/linux-arm64-gnu)

Updates `@next/swc-linux-arm64-musl` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/linux-arm64-musl)

Updates `@next/swc-linux-x64-gnu` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/linux-x64-gnu)

Updates `@next/swc-linux-x64-musl` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/linux-x64-musl)

Updates `@next/swc-win32-arm64-msvc` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/win32-arm64-msvc)

Updates `@next/swc-win32-x64-msvc` from 15.3.2 to 15.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.3/crates/napi/npm/win32-x64-msvc)

Updates `@rollup/rollup-android-arm-eabi` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-android-arm64` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-darwin-arm64` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-darwin-x64` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-freebsd-arm64` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-freebsd-x64` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-arm64-musl` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-powerpc64le-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-x64-gnu` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-linux-x64-musl` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@rollup/rollup-win32-x64-msvc` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `@tailwindcss/node` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/packages/@tailwindcss-node)

Updates `@tailwindcss/oxide-android-arm64` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/android-arm64)

Updates `@tailwindcss/oxide-darwin-arm64` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/darwin-arm64)

Updates `@tailwindcss/oxide-darwin-x64` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/darwin-x64)

Updates `@tailwindcss/oxide-freebsd-x64` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/freebsd-x64)

Updates `@tailwindcss/oxide-linux-arm-gnueabihf` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/linux-arm-gnueabihf)

Updates `@tailwindcss/oxide-linux-arm64-gnu` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/linux-arm64-gnu)

Updates `@tailwindcss/oxide-linux-arm64-musl` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/linux-arm64-musl)

Updates `@tailwindcss/oxide-linux-x64-gnu` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/linux-x64-gnu)

Updates `@tailwindcss/oxide-linux-x64-musl` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/linux-x64-musl)

Updates `@tailwindcss/oxide-wasm32-wasi` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node)

Updates `@tailwindcss/oxide-win32-arm64-msvc` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/win32-arm64-msvc)

Updates `@tailwindcss/oxide-win32-x64-msvc` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node/npm/win32-x64-msvc)

Updates `@tailwindcss/oxide` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.8/crates/node)

Updates `@typescript-eslint/scope-manager` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/scope-manager/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/scope-manager)

Updates `@typescript-eslint/type-utils` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/type-utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/type-utils)

Updates `@typescript-eslint/types` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/types/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/types)

Updates `@typescript-eslint/typescript-estree` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/typescript-estree)

Updates `@typescript-eslint/utils` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/utils)

Updates `@typescript-eslint/visitor-keys` from 8.32.1 to 8.33.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/visitor-keys/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.33.0/packages/visitor-keys)

Updates `browserslist` from 4.24.5 to 4.25.0
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.24.5...4.25.0)

Updates `caniuse-lite` from 1.0.30001718 to 1.0.30001720
- [Commits](https://github.com/browserslist/caniuse-lite/compare/1.0.30001718...1.0.30001720)

Updates `commander` from 13.1.0 to 14.0.0
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tj/commander.js/compare/v13.1.0...v14.0.0)

Updates `electron-to-chromium` from 1.5.157 to 1.5.161
- [Changelog](https://github.com/Kilian/electron-to-chromium/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kilian/electron-to-chromium/compare/v1.5.157...v1.5.161)

Updates `es-abstract` from 1.23.10 to 1.24.0
- [Changelog](https://github.com/ljharb/es-abstract/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/es-abstract/compare/v1.23.10...v1.24.0)

Updates `esbuild` from 0.25.4 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.4...v0.25.5)

Updates `fdir` from 6.4.4 to 6.4.5
- [Release notes](https://github.com/thecodrr/fdir/releases)
- [Commits](https://github.com/thecodrr/fdir/compare/v6.4.4...v6.4.5)

Updates `onetime` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/sindresorhus/onetime/releases)
- [Commits](https://github.com/sindresorhus/onetime/compare/v6.0.0...v7.0.0)

Updates `react-remove-scroll` from 2.7.0 to 2.7.1
- [Release notes](https://github.com/theKashey/react-remove-scroll/releases)
- [Changelog](https://github.com/theKashey/react-remove-scroll/blob/master/CHANGELOG.md)
- [Commits](https://github.com/theKashey/react-remove-scroll/compare/v2.7.0...v2.7.1)

Updates `rollup` from 4.41.0 to 4.41.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.41.0...v4.41.1)

Updates `tinyglobby` from 0.2.13 to 0.2.14
- [Release notes](https://github.com/SuperchupuDev/tinyglobby/releases)
- [Changelog](https://github.com/SuperchupuDev/tinyglobby/blob/main/CHANGELOG.md)
- [Commits](https://github.com/SuperchupuDev/tinyglobby/compare/0.2.13...0.2.14)

Updates `unrs-resolver` from 1.7.2 to 1.7.8
- [Release notes](https://github.com/unrs/unrs-resolver/releases)
- [Changelog](https://github.com/unrs/unrs-resolver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unrs/unrs-resolver/compare/v1.7.2...v1.7.8)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: sonner
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@eslint/js"
  dependency-version: 9.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@next/eslint-plugin-next"
  dependency-version: 15.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@types/node"
  dependency-version: 22.15.29
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@types/react"
  dependency-version: 19.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: eslint
  dependency-version: 9.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: eslint-config-next
  dependency-version: 15.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: lint-staged
  dependency-version: 16.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: tailwindcss
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: tw-animate-css
  dependency-version: 1.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: typescript-eslint
  dependency-version: 8.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@babel/compat-data"
  dependency-version: 7.27.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/core"
  dependency-version: 7.27.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/generator"
  dependency-version: 7.27.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/helper-module-transforms"
  dependency-version: 7.27.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/helpers"
  dependency-version: 7.27.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/parser"
  dependency-version: 7.27.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/traverse"
  dependency-version: 7.27.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@babel/types"
  dependency-version: 7.27.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/aix-ppc64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/android-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/android-arm"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/android-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/darwin-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/darwin-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/freebsd-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/freebsd-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-arm"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-ia32"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-loong64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-mips64el"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-ppc64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-riscv64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-s390x"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/linux-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/netbsd-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/netbsd-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/openbsd-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/openbsd-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/sunos-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/win32-arm64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/win32-ia32"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@esbuild/win32-x64"
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/env"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/node"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-android-arm64"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-darwin-arm64"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-darwin-x64"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-freebsd-x64"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-linux-arm-gnueabihf"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-linux-arm64-gnu"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-linux-arm64-musl"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-linux-x64-gnu"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-linux-x64-musl"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-wasm32-wasi"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-win32-arm64-msvc"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide-win32-x64-msvc"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@tailwindcss/oxide"
  dependency-version: 4.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: "@typescript-eslint/scope-manager"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/type-utils"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/types"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: "@typescript-eslint/visitor-keys"
  dependency-version: 8.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: browserslist
  dependency-version: 4.25.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: caniuse-lite
  dependency-version: 1.0.30001720
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: commander
  dependency-version: 14.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: electron-to-chromium
  dependency-version: 1.5.161
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: es-abstract
  dependency-version: 1.24.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: esbuild
  dependency-version: 0.25.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: fdir
  dependency-version: 6.4.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: onetime
  dependency-version: 7.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: react-remove-scroll
  dependency-version: 2.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: rollup
  dependency-version: 4.41.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: tinyglobby
  dependency-version: 0.2.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: unrs-resolver
  dependency-version: 1.7.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 02:12:41 +00:00
zhom 1950ef0098 docs: typo fix 2025-06-02 05:10:48 +04:00
zhom 814875c28e docs: add option for urgent contact 2025-06-02 05:10:10 +04:00
zhom b06ca4f11e chore: set the correct license in package.json 2025-06-01 05:32:27 +04:00
zhom 3ab1ea61e8 fix: run correct lint command in ci 2025-05-31 18:25:32 +04:00
zhom a0599ecfc1 test: fix update from dev to nightly test 2025-05-31 18:19:47 +04:00
zhom 6c834b3003 test: fix dev to nightly update check 2025-05-31 18:10:21 +04:00
zhom 269b4dbe77 chore: version bump 2025-05-31 17:23:56 +04:00
zhom ef00854307 build: don't check for updates on dev version 2025-05-31 17:19:29 +04:00
zhom 03d915e5c7 fix: prevent update browser toasts getting stuck in a cycle 2025-05-31 17:14:10 +04:00
zhom 91b12e80e5 fix: treat 'twilight' release as alpha and rolling 2025-05-31 16:53:11 +04:00
zhom 3af581c4ab fix: prevent hydration errors for theme provider 2025-05-31 16:48:36 +04:00
zhom 7a85edfb8a test: mock network requests inside browser_version_service 2025-05-31 15:45:01 +04:00
zhom 141a5f06a4 docs: add a note that the app has automatic updates 2025-05-31 12:57:01 +04:00
zhom 7a3857c06a chore: version bump test 2025-05-31 12:53:50 +04:00
zhom ed26786fdb chore: cargo fmt 2025-05-31 12:38:14 +04:00
zhom 966268ff05 chore: version bump 2025-05-31 12:37:29 +04:00
zhom 87ae696d7a refactor: make app_auto_updater use shared extraction logic 2025-05-31 12:31:16 +04:00
zhom 7e92b290b6 chore: update description and readme 2025-05-31 12:14:54 +04:00
zhom eb62e0abf9 chor: bump version 2025-05-31 11:39:23 +04:00
zhom 0ed5adf2ba chore: cargo fmt 2025-05-31 11:09:42 +04:00
zhom dd91aaeea0 chore: don't make regular release a draft 2025-05-31 11:08:50 +04:00
zhom 6a3407796d chore: version bump and build update 2025-05-31 11:07:59 +04:00
72 changed files with 9661 additions and 4734 deletions
+8 -8
View File
@@ -1,14 +1,14 @@
# ✨ Pull Request
### 📓 Referenced Issue
## 📓 Referenced Issue
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
### ️ About the PR
## ️ About the PR
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
### 🔄 Type of Change
## 🔄 Type of Change
<!-- Mark the relevant option with an "x". -->
@@ -19,11 +19,11 @@
- [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ Performance improvement
### 🖼️ Testing Scenarios / Screenshots
## 🖼️ Testing Scenarios / Screenshots
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
### ✅ Checklist
## ✅ Checklist
<!-- Mark completed items with an "x". -->
@@ -36,11 +36,11 @@
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
### 🧪 How Has This Been Tested?
## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. -->
### 📱 Platform Testing
## 📱 Platform Testing
<!-- Which platforms have you tested on? -->
@@ -49,6 +49,6 @@
- [ ] Windows (if applicable)
- [ ] Linux (if applicable)
### 📋 Additional Notes
## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. -->
+46 -4
View File
@@ -1,28 +1,70 @@
version: 2
updates:
# Enable version updates for Node.js dependencies
# Frontend dependencies (root package.json)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
all:
frontend-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "eslint"
versions: ">= 9"
commit-message:
prefix: "deps"
include: "scope"
# Enable version updates for rust
# 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"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
all:
rust-dependencies:
patterns:
- "*"
commit-message:
prefix: "deps(rust)"
include: "scope"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: "ci"
include: "scope"
+47 -21
View File
@@ -1,34 +1,60 @@
# Automatically squashes and merges Dependabot dependency upgrades if tests pass
name: Dependabot Automerge
name: Dependabot Auto-merge
on: pull_request_target
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: write
checks: read
jobs:
dependabot:
runs-on: ubuntu-latest
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: Fetch Dependabot metadata
id: dependabot-metadata
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve Dependabot PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge (squash) Dependabot PR
if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
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
+3 -1
View File
@@ -13,6 +13,8 @@ on:
paths-ignore:
- "src-tauri/**"
- "README.md"
- ".github/workflows/lint-rs.yml"
- ".github/workflows/osv.yml"
jobs:
build:
@@ -49,4 +51,4 @@ jobs:
pnpm install --frozen-lockfile
- name: Run lint step
run: pnpm lint
run: pnpm run lint:js
+17 -4
View File
@@ -12,11 +12,17 @@ on:
pull_request:
paths-ignore:
- "src/**"
- "nodecar/**"
- "package.json"
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "yarn.lock"
- "README.md"
- ".github/workflows/lint-js.yml"
- ".github/workflows/osv.yml"
- "next.config.js"
- "tailwind.config.js"
- "tsconfig.json"
- "biome.json"
jobs:
build:
@@ -49,6 +55,9 @@ jobs:
with:
components: rustfmt, clippy
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-latest'
run: |
@@ -61,7 +70,7 @@ jobs:
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --ignore-workspace --frozen-lockfile
pnpm install --frozen-lockfile
- name: Build nodecar binary
shell: bash
@@ -70,7 +79,7 @@ jobs:
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:aarch64
pnpm run build:mac-aarch64
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
pnpm run build:win-x64
fi
@@ -101,3 +110,7 @@ jobs:
- name: Run Rust unit tests
run: cargo test
working-directory: src-tauri
- name: Run cargo audit security check
run: cargo audit
working-directory: src-tauri
+74
View File
@@ -0,0 +1,74 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
# in addition to a PR check which fails if new vulnerabilities are introduced.
#
# For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/
# Security vulnerability scanning for Donut Browser
# Scans dependencies in package managers (npm/pnpm, Cargo) for known vulnerabilities
# Runs on schedule and when dependencies change
name: Security Vulnerability Scan
on:
pull_request:
branches: ["main"]
paths:
- "package.json"
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
- ".github/workflows/osv.yml"
merge_group:
branches: ["main"]
schedule:
# Run weekly on Tuesdays at 2:20 PM UTC
- cron: "20 14 * * 2"
push:
branches: ["main"]
paths:
- "package.json"
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
permissions:
security-events: write
contents: read
actions: read
jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.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
./
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
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
./
+50
View File
@@ -0,0 +1,50 @@
name: Pull Request Checks
on:
pull_request:
branches: ["main"]
merge_group:
branches: ["main"]
permissions:
security-events: write
contents: read
actions: read
jobs:
lint-js:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
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=nodecar/pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
./
pr-status:
name: PR Status Check
runs-on: ubuntu-latest
needs: [lint-js, lint-rust, security-scan]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
echo "One or more checks failed"
exit 1
fi
echo "All checks passed!"
+35 -19
View File
@@ -11,6 +11,22 @@ env:
STABLE_RELEASE: "true"
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.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
uses: ./.github/workflows/lint-js.yml
@@ -22,7 +38,7 @@ jobs:
secrets: inherit
release:
needs: [lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust]
permissions:
contents: write
strategy:
@@ -34,26 +50,26 @@ jobs:
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
# Future platforms can be added here:
# - platform: "ubuntu-20.04"
# args: "--target x86_64-unknown-linux-gnu"
# arch: "x86_64"
# target: "x86_64-unknown-linux-gnu"
# pkg_target: "latest-linux-x64"
# nodecar_script: "build:linux-x64"
# - platform: "ubuntu-20.04"
# args: "--target aarch64-unknown-linux-gnu"
# arch: "aarch64"
# target: "aarch64-unknown-linux-gnu"
# pkg_target: "latest-linux-arm64"
# nodecar_script: "build:linux-arm64"
# - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64"
@@ -85,10 +101,10 @@ jobs:
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@v2
@@ -101,7 +117,7 @@ jobs:
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --ignore-workspace --frozen-lockfile
pnpm install --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
@@ -131,6 +147,6 @@ jobs:
tagName: ${{ github.ref_name }}
releaseName: "Donut Browser ${{ github.ref_name }}"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
+44 -5
View File
@@ -10,6 +10,22 @@ env:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.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
uses: ./.github/workflows/lint-js.yml
@@ -21,7 +37,7 @@ jobs:
secrets: inherit
rolling-release:
needs: [lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust]
permissions:
contents: write
strategy:
@@ -33,13 +49,25 @@ jobs:
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
runs-on: ${{ matrix.platform }}
steps:
@@ -58,6 +86,12 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
@@ -69,7 +103,7 @@ jobs:
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --ignore-workspace --frozen-lockfile
pnpm install --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
@@ -81,7 +115,11 @@ jobs:
shell: bash
run: |
mkdir -p src-tauri/binaries
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
else
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Build frontend
run: pnpm build
@@ -94,6 +132,7 @@ jobs:
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 }}"
GITHUB_SHA: ${{ github.sha }}
with:
+9 -2
View File
@@ -5,6 +5,10 @@
/.pnp
.pnp.js
# npm/yarn lock files (project uses pnpm only)
**/package-lock.json
**/yarn.lock
# testing
/coverage
@@ -30,8 +34,8 @@ yarn-error.log*
.pnpm-debug.log*
# nodecar
nodecar/dist
nodecar/node_modules
**/dist
**/node_modules
# local env files
.env*.local
@@ -42,4 +46,7 @@ nodecar/node_modules
# typescript
*.tsbuildinfo
# eslint
.eslintcache
!**/.gitkeep
+1 -1
View File
@@ -1 +1 @@
pnpm lint-staged
pnpm exec lint-staged
+1 -1
View File
@@ -1,2 +1,2 @@
22
23
+1
View File
@@ -0,0 +1 @@
23
+11
View File
@@ -0,0 +1,11 @@
{
"recommendations": [
"biomejs.biome",
"streetsidesoftware.code-spell-checker",
"usernamehw.errorlens",
"heybourn.headwind",
"yoavbls.pretty-ts-errors",
"rust-lang.rust-analyzer",
"bradlc.vscode-tailwindcss"
]
}
+51 -1
View File
@@ -1,23 +1,73 @@
{
"cSpell.words": [
"appimage",
"appindicator",
"applescript",
"autoconfig",
"autologin",
"biomejs",
"cdylib",
"CFURL",
"checkin",
"clippy",
"codegen",
"devedition",
"donutbrowser",
"dpkg",
"dtolnay",
"elif",
"esbuild",
"eslintcache",
"frontmost",
"gifs",
"gsettings",
"icns",
"idletime",
"KHTML",
"launchservices",
"libatk",
"libayatana",
"libcairo",
"libgdk",
"libglib",
"libpango",
"librsvg",
"libwebkit",
"mountpoint",
"msvc",
"msys",
"Mullvad",
"nodecar",
"ntlm",
"objc",
"osascript",
"pixbuf",
"plasmohq",
"propertylist",
"reqwest",
"ridedott",
"rlib",
"rustc",
"SARIF",
"serde",
"shadcn",
"signon",
"sonner",
"sspi",
"staticlib",
"subdirs",
"swatinem",
"sysinfo",
"systempreferences",
"turbopack"
"tauri",
"titlebar",
"Torbrowser",
"turbopack",
"unlisten",
"unrs",
"vercel",
"wiremock",
"xattr",
"zhom"
]
}
+1 -1
View File
@@ -50,7 +50,7 @@ After having the above dependencies installed, proceed through the following ste
```bash
cd nodecar
pnpm install --ignore-workspace --frozen-lockfile
pnpm install --frozen-lockfile
cd ..
```
+21 -22
View File
@@ -1,44 +1,35 @@
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
# Donut Browser
<p align="center">
<a href="https://github.com/zhom/donutbrowser/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/v/release/zhom/donutbrowser">
</a>
<a href="https://github.com/zhom/donutbrowser/issues" target="_blank">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs Welcome">
</a>
<a 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://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
</p>
![Donut Browser Logo](assets/logo.png)
## Donut Browser
> **A powerful browser orchestrator that puts you in control of your browsing experience. 🍩**
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
[![GitHub Release](https://img.shields.io/github/v/release/zhom/donutbrowser)](https://github.com/zhom/donutbrowser/releases/latest)
[![RPs Welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://github.com/zhom/donutbrowser/issues)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://github.com/zhom/donutbrowser/blob/main/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/zhom/donutbrowser?style=social)](https://github.com/zhom/donutbrowser/stargazers)
![Donut Browser Preview](assets/preview.png)
## Download
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
## Supported Platforms
-**macOS** (Intel & Apple Silicon)
-**Linux** (x64 & arm64)
- 🔄 **Windows** (Planned)
- 🔄 **Linux** (Planned)
## Development
### Contributing
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Issues
@@ -52,6 +43,14 @@ Have questions or want to contribute? We'd love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date)](https://www.star-history.com/#zhom/donutbrowser&Date)
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
## License
This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) file for details.
+40
View File
@@ -0,0 +1,40 @@
# Security Policy
## Reporting Security Issues
Thanks for helping make Donut Browser safe for everyone! ❤️
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit the issue
- Your assessment of the severity level
This information will help us triage your report more quickly.
## What to Expect
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
## Contact
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For general questions about this security policy, you can also reach out through:
- [GitHub Issues](https://github.com/zhom/donutbrowser/issues) (for non-security questions only)
- [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
+2 -2
View File
@@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Determine file extension based on platform
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
EXT=".exe"
else
EXT=""
fi
# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE
if [ -n "$1" ]; then
TARGET_TRIPLE="$1"
else
RUST_INFO=$(rustc -vV)
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
fi
# Check if target triple was found
if [ -z "$TARGET_TRIPLE" ]; then
echo "Failed to determine platform target triple" >&2
exit 1
fi
# Copy the file
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
+13 -13
View File
@@ -7,26 +7,26 @@
"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",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar"
"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",
"build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar && pnpm rename-binary",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar && pnpm rename-binary",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar && pnpm rename-binary",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar && pnpm rename-binary",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar && pnpm rename-binary"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.6.1",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^22.15.17",
"@yao-pkg/pkg": "^6.4.1",
"commander": "^13.1.0",
"@types/node": "^22.15.30",
"@yao-pkg/pkg": "^6.5.1",
"commander": "^14.0.0",
"dotenv": "^16.5.0",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.8",
"proxy-chain": "^2.5.9",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
}
-1304
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
const ext = process.platform === "win32" ? ".exe" : "";
const rustInfo = execSync("rustc -vV");
const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];
if (!targetTriple) {
console.error("Failed to determine platform target triple");
}
fs.renameSync(
`dist/nodecar${ext}`,
`../src-tauri/binaries/nodecar-${targetTriple}${ext}`
);
+2 -2
View File
@@ -48,8 +48,8 @@ program
ignoreProxyCertificate: options.ignoreCertificate,
});
console.log(JSON.stringify(config));
} catch (error: any) {
console.error(`Failed to start proxy: ${error.message}`);
} catch (error: unknown) {
console.error(`Failed to start proxy: ${JSON.stringify(error)}`);
}
} else if (action === "stop") {
if (options.id) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { spawn } from "child_process";
import path from "path";
import { spawn } from "node:child_process";
import path from "node:path";
import getPort from "get-port";
import {
type ProxyConfig,
+3 -3
View File
@@ -1,6 +1,6 @@
import fs from "fs";
import path from "path";
import os from "os";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
// Define the proxy configuration type
export interface ProxyConfig {
+32 -25
View File
@@ -1,21 +1,26 @@
{
"name": "donutbrowser",
"private": true,
"version": "0.1.0",
"license": "AGPL-3.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/ && tsc --noEmit && next lint",
"test": "pnpm test:rust",
"test:rust": "cd src-tauri && cargo test",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit && next lint",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky",
"format:js": "biome check src/ --fix",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:biome": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust"
"format:js": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust",
"cargo": "cd src-tauri && cargo",
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
@@ -30,46 +35,48 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "~2.3.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"next": "^15.3.2",
"next": "^15.3.3",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.3",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.27.0",
"@next/eslint-plugin-next": "^15.3.2",
"@tailwindcss/postcss": "^4.1.7",
"@eslint/js": "^9.28.0",
"@next/eslint-plugin-next": "^15.3.3",
"@tailwindcss/postcss": "^4.1.8",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.21",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.5.0",
"eslint": "^9.27.0",
"eslint-config-next": "^15.3.2",
"@types/node": "^22.15.30",
"@types/react": "^19.1.6",
"@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",
"eslint-config-next": "^15.3.3",
"eslint-plugin-react-hooks": "^5.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"lint-staged": "^16.1.0",
"tailwindcss": "^4.1.8",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.32.1"
"typescript-eslint": "^8.33.1"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix"
"biome check --fix",
"eslint --cache --fix"
],
"src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all",
+1859 -985
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,5 +1,9 @@
packages:
- "nodecar"
onlyBuiltDependencies:
- '@biomejs/biome'
- '@tailwindcss/oxide'
- "@biomejs/biome"
- "@tailwindcss/oxide"
- esbuild
- sharp
- unrs-resolver
+581 -710
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,9 +1,10 @@
[package]
name = "donutbrowser"
version = "0.1.0"
description = "A Tauri App"
version = "0.3.0"
description = "Simple Yet Powerful Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
default-run = "donutbrowser"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -25,6 +26,7 @@ tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] }
@@ -37,6 +39,8 @@ futures-util = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation="0.10"
objc2 = "0.6.1"
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
[dev-dependencies]
tempfile = "3.13.0"
+1 -1
View File
@@ -13,7 +13,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.3.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
+5 -2
View File
@@ -6,8 +6,11 @@ fn main() {
}
// Inject build version based on environment variables set by CI
if let Ok(tag_name) = std::env::var("GITHUB_REF_NAME") {
// This is set by GitHub Actions to the tag name (e.g., "v1.0.0" or "nightly-abc123")
if let Ok(build_tag) = std::env::var("BUILD_TAG") {
// Custom BUILD_TAG takes highest priority (used for nightly builds)
println!("cargo:rustc-env=BUILD_VERSION={build_tag}");
} else if let Ok(tag_name) = std::env::var("GITHUB_REF_NAME") {
// This is set by GitHub Actions to the tag name (e.g., "v1.0.0")
println!("cargo:rustc-env=BUILD_VERSION={tag_name}");
} else if std::env::var("STABLE_RELEASE").is_ok() {
// Fallback for stable releases - use CARGO_PKG_VERSION with 'v' prefix
+8 -1
View File
@@ -6,6 +6,11 @@
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
"fs:default",
"shell:allow-execute",
@@ -13,6 +18,8 @@
"shell:allow-open",
"shell:allow-spawn",
"shell:allow-stdin-write",
"deep-link:default"
"deep-link:default",
"dialog:default",
"dialog:allow-open"
]
}
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Donut Browser
Comment=Simple Yet Powerful Browser Orchestrator
Exec=donutbrowser %u
Icon=donutbrowser
StartupNotify=true
NoDisplay=false
Categories=Network;WebBrowser;Productivity;
MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
StartupWMClass=donutbrowser
Keywords=browser;web;internet;productivity;
+321 -117
View File
@@ -36,12 +36,16 @@ impl VersionComponent {
let version = version.trim();
// Handle special case for Zen Browser twilight releases
if version.to_lowercase().contains("twilight") {
if version.to_lowercase() == "twilight" {
// Pure twilight release without base version
return VersionComponent {
major: u32::MAX,
minor: u32::MAX,
patch: u32::MAX,
pre_release: None,
major: 999, // High major version to indicate it's a rolling release
minor: 0,
patch: 0,
pre_release: Some(PreRelease {
kind: PreReleaseKind::Alpha,
number: Some(999), // High number to indicate it's a rolling release
}),
};
}
@@ -140,6 +144,38 @@ impl Ord for VersionComponent {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
// Check for twilight versions
let self_is_twilight = self
.pre_release
.as_ref()
.map(|pr| pr.kind == PreReleaseKind::Alpha && pr.number == Some(999))
.unwrap_or(false);
let other_is_twilight = other
.pre_release
.as_ref()
.map(|pr| pr.kind == PreReleaseKind::Alpha && pr.number == Some(999))
.unwrap_or(false);
// If one is twilight and the other isn't, twilight always has priority
if self_is_twilight && !other_is_twilight {
return Ordering::Greater; // twilight > non-twilight
}
if !self_is_twilight && other_is_twilight {
return Ordering::Less; // non-twilight < twilight
}
// Both are twilight or both are not twilight - use normal comparison
match (self_is_twilight, other_is_twilight) {
(true, true) => {
// Both are twilight, compare by base version
return (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
}
(false, false) => {
// Neither is twilight, continue with normal comparison
}
_ => unreachable!(), // Already handled above
}
// Compare major.minor.patch first
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
Ordering::Equal => {
@@ -188,11 +224,17 @@ pub fn sort_github_releases(releases: &mut [GithubRelease]) {
});
}
pub fn is_alpha_version(version: &str) -> bool {
pub fn is_nightly_version(version: &str) -> bool {
let version_comp = VersionComponent::parse(version);
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_nightly_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FirefoxRelease {
pub build_number: u32,
@@ -273,7 +315,7 @@ impl ApiClient {
}
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -343,7 +385,7 @@ impl ApiClient {
&self,
browser: &str,
versions: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
let cache_file = cache_dir.join(format!("{browser}_versions.json"));
@@ -378,7 +420,7 @@ impl ApiClient {
&self,
browser: &str,
releases: &[GithubRelease],
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
let cache_file = cache_dir.join(format!("{browser}_github.json"));
@@ -407,7 +449,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -425,7 +467,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -492,7 +534,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -510,7 +552,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -569,13 +611,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_mullvad_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_mullvad_releases_with_caching(false).await
}
pub async fn fetch_mullvad_releases_with_caching(
&self,
no_caching: bool,
@@ -589,13 +624,13 @@ impl ApiClient {
println!("Fetching Mullvad releases from GitHub API...");
let url = format!(
"{}/repos/mullvad/mullvad-browser/releases",
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
@@ -604,7 +639,7 @@ impl ApiClient {
let mut releases: Vec<GithubRelease> = releases
.into_iter()
.map(|mut release| {
release.is_alpha = release.prerelease;
release.is_nightly = release.prerelease;
release
})
.collect();
@@ -622,13 +657,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_zen_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_zen_releases_with_caching(false).await
}
pub async fn fetch_zen_releases_with_caching(
&self,
no_caching: bool,
@@ -642,19 +670,37 @@ impl ApiClient {
println!("Fetching Zen releases from GitHub API...");
let url = format!(
"{}/repos/zen-browser/desktop/releases",
"{}/repos/zen-browser/desktop/releases?per_page=100",
self.github_api_base
);
let mut releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
.await?;
// Sort releases using the new version sorting system (twilight releases will be at top)
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_nightly = is_zen_nightly_version(&release.tag_name);
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
if let Ok(has_update) = self.check_twilight_update(release).await {
if has_update {
println!(
"Detected update for Zen twilight release: {}",
release.tag_name
);
}
}
}
}
// Sort releases using the new version sorting system
sort_github_releases(&mut releases);
// Cache the results (unless bypassing cache)
@@ -667,13 +713,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_brave_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_brave_releases_with_caching(false).await
}
pub async fn fetch_brave_releases_with_caching(
&self,
no_caching: bool,
@@ -687,32 +726,32 @@ impl ApiClient {
println!("Fetching Brave releases from GitHub API...");
let url = format!(
"{}/repos/brave/brave-browser/releases",
"{}/repos/brave/brave-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
.await?;
// Filter releases that have universal macOS DMG assets
// Get platform info to filter appropriate releases
let (os, arch) = Self::get_platform_info();
// Filter releases that have assets compatible with the current platform
let mut filtered_releases: Vec<GithubRelease> = releases
.into_iter()
.filter_map(|mut release| {
// Check if this release has a universal DMG asset
let has_universal_dmg = release
.assets
.iter()
.any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"));
// Check if this release has compatible assets for the current platform
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
if has_universal_dmg {
// Set is_alpha based on the release name
// Nightly releases contain "Nightly", stable contain "Release"
release.is_alpha = release.name.to_lowercase().contains("nightly");
if has_compatible_asset {
// Set is_nightly based on the release name
// Stable releases start with "Release", everything else is nightly
release.is_nightly = !release.name.starts_with("Release");
Some(release)
} else {
None
@@ -733,6 +772,72 @@ impl ApiClient {
Ok(filtered_releases)
}
/// Check if a Brave release has compatible assets for the given platform and architecture
fn has_compatible_brave_asset(
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> bool {
match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
}) || assets.iter().any(|asset| asset.name.ends_with(".exe"))
}
"macos" => {
// For macOS, prefer universal DMG
assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
}
"linux" => {
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.ends_with(".deb") || name.ends_with(".rpm")
})
}
_ => false,
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
pub async fn fetch_chromium_latest_version(
&self,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -746,7 +851,7 @@ impl ApiClient {
let version = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -829,7 +934,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Assume all archived versions are stable
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
@@ -846,7 +951,7 @@ impl ApiClient {
let html = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -926,7 +1031,7 @@ impl ApiClient {
let html = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -935,12 +1040,89 @@ impl ApiClient {
// Check if there's a macOS DMG file in this version directory
Ok(html.contains("tor-browser-macos-") && html.contains(".dmg"))
}
/// Check if a Zen twilight release has been updated by comparing file size
pub async fn check_twilight_update(
&self,
release: &GithubRelease,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
if release.tag_name.to_lowercase() != "twilight" {
return Ok(false); // Not a twilight release
}
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
.ok_or("No macOS universal asset found for twilight release")?;
// Check if we have cached file size information
let cache_dir = Self::get_cache_dir()?;
let twilight_cache_file = cache_dir.join("zen_twilight_info.json");
#[derive(serde::Serialize, serde::Deserialize)]
struct TwilightInfo {
file_size: u64,
last_updated: u64,
download_url: String,
}
let current_info = TwilightInfo {
file_size: asset.size,
last_updated: Self::get_current_timestamp(),
download_url: asset.browser_download_url.clone(),
};
if !twilight_cache_file.exists() {
// No cache exists, save current info and return true (new)
let content = serde_json::to_string_pretty(&current_info)?;
fs::write(&twilight_cache_file, content)?;
return Ok(true);
}
let cached_content = fs::read_to_string(&twilight_cache_file)?;
let cached_info: TwilightInfo = serde_json::from_str(&cached_content)?;
// Check if file size has changed
if cached_info.file_size != current_info.file_size {
// File size changed, update cache and return true
let content = serde_json::to_string_pretty(&current_info)?;
fs::write(&twilight_cache_file, content)?;
println!(
"Zen twilight release updated: file size changed from {} to {}",
cached_info.file_size, current_info.file_size
);
return Ok(true);
}
Ok(false) // No update detected
}
pub fn clear_all_cache(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
if cache_dir.exists() {
// Remove all cache files
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
fs::remove_file(&path)?;
println!("Removed cache file: {path:?}");
}
}
println!("All version cache cleared successfully");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -989,10 +1171,14 @@ mod tests {
assert_eq!(pre.number, Some(5));
// Test twilight version (Zen Browser)
let v4 = VersionComponent::parse("1.0.0-twilight");
assert_eq!(v4.major, u32::MAX);
assert_eq!(v4.minor, u32::MAX);
assert_eq!(v4.patch, u32::MAX);
let v4 = VersionComponent::parse("twilight");
assert_eq!(v4.major, 999);
assert_eq!(v4.minor, 0);
assert_eq!(v4.patch, 0);
assert!(v4.pre_release.is_some());
let pre = v4.pre_release.unwrap();
assert_eq!(pre.kind, PreReleaseKind::Alpha);
assert_eq!(pre.number, Some(999));
}
#[test]
@@ -1022,10 +1208,15 @@ mod tests {
let v10 = VersionComponent::parse("137.0b5");
assert!(v10 > v9); // b5 > b4
// Test twilight version (should be highest)
let v11 = VersionComponent::parse("1.0.0-twilight");
let v12 = VersionComponent::parse("999.999.999");
assert!(v11 > v12);
// Test twilight version (should have highest priority)
let v11 = VersionComponent::parse("twilight");
let v12 = VersionComponent::parse("1.0.0");
assert!(v11 > v12); // twilight > stable due to high major version
// Test twilight vs other pre-releases
let v13 = VersionComponent::parse("twilight");
let v14 = VersionComponent::parse("1.0.0a1");
assert!(v13 > v14); // twilight > a1 due to high major version
}
#[test]
@@ -1037,14 +1228,14 @@ mod tests {
"137.0b4".to_string(),
"137.0b5".to_string(),
"137.0".to_string(),
"1.0.0-twilight".to_string(),
"twilight".to_string(),
"2.0.0a1".to_string(),
];
sort_versions(&mut versions);
// Expected order: twilight, 137.0, 137.0b5, 137.0b4, 2.0.0a1, 1.12.6b, 1.10.0, 1.9.9b
assert_eq!(versions[0], "1.0.0-twilight");
// Expected order with twilight priority: twilight first due to high major version (999), then normal semantic versioning
assert_eq!(versions[0], "twilight");
assert_eq!(versions[1], "137.0");
assert_eq!(versions[2], "137.0b5");
assert_eq!(versions[3], "137.0b4");
@@ -1054,6 +1245,31 @@ mod tests {
assert_eq!(versions[7], "1.9.9b");
}
#[test]
fn test_sort_versions_comprehensive() {
let mut versions = vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.1.0".to_string(),
"2.0.0a1".to_string(),
"2.0.0b1".to_string(),
"2.0.0rc1".to_string(),
"2.0.0".to_string(),
"10.0.0".to_string(),
"twilight".to_string(),
];
sort_versions(&mut versions);
// Expected order with twilight priority: twilight first due to high major version (999), then normal semantic versioning
assert_eq!(versions[0], "twilight");
assert_eq!(versions[1], "10.0.0");
assert_eq!(versions[2], "2.0.0");
assert_eq!(versions[3], "2.0.0rc1");
assert_eq!(versions[4], "2.0.0b1");
assert_eq!(versions[5], "2.0.0a1");
}
#[tokio::test]
async fn test_firefox_api() {
let server = setup_mock_server().await;
@@ -1084,7 +1300,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1095,6 +1310,9 @@ mod tests {
let result = client.fetch_firefox_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Firefox API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1128,7 +1346,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1141,6 +1358,9 @@ mod tests {
.fetch_firefox_developer_releases_with_caching(true)
.await;
if let Err(e) = &result {
println!("Firefox Developer API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1167,7 +1387,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
"size": 100000000
}
]
}
@@ -1175,7 +1396,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1190,7 +1411,7 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_alpha);
assert!(releases[0].is_nightly);
}
#[tokio::test]
@@ -1200,14 +1421,15 @@ mod tests {
let mock_response = r#"[
{
"tag_name": "1.0.0-twilight",
"tag_name": "twilight",
"name": "Zen Browser Twilight",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-twilight.dmg"
"browser_download_url": "https://example.com/zen-twilight.dmg",
"size": 120000000
}
]
}
@@ -1215,7 +1437,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1229,7 +1451,7 @@ mod tests {
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "1.0.0-twilight");
assert_eq!(releases[0].tag_name, "twilight");
}
#[tokio::test]
@@ -1246,7 +1468,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
@@ -1254,7 +1477,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1265,11 +1488,14 @@ mod tests {
let result = client.fetch_brave_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Brave API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(!releases[0].is_alpha);
assert!(releases[0].is_nightly);
}
#[tokio::test]
@@ -1285,7 +1511,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1314,7 +1539,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1357,7 +1581,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -1368,7 +1591,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1379,7 +1601,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3"))
@@ -1417,7 +1638,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1447,7 +1667,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.5/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1463,37 +1682,24 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
assert!(is_alpha_version("1.2.3a1"));
assert!(is_alpha_version("137.0b5"));
assert!(is_alpha_version("140.0rc1"));
assert!(!is_alpha_version("139.0"));
assert!(!is_alpha_version("1.2.3"));
fn test_is_nightly_version() {
assert!(is_nightly_version("1.2.3a1"));
assert!(is_nightly_version("137.0b5"));
assert!(is_nightly_version("140.0rc1"));
assert!(!is_nightly_version("139.0"));
assert!(!is_nightly_version("1.2.3"));
}
#[test]
fn test_sort_versions_comprehensive() {
let mut versions = vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.1.0".to_string(),
"2.0.0a1".to_string(),
"2.0.0b1".to_string(),
"2.0.0rc1".to_string(),
"2.0.0".to_string(),
"10.0.0".to_string(),
"1.0.0-twilight".to_string(),
];
fn test_is_zen_nightly_version() {
// Only "twilight" should be considered nightly for Zen Browser
assert!(is_zen_nightly_version("twilight"));
assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
sort_versions(&mut versions);
// Twilight should be first, then normal semantic versioning
assert_eq!(versions[0], "1.0.0-twilight");
assert_eq!(versions[1], "10.0.0");
assert_eq!(versions[2], "2.0.0");
assert_eq!(versions[3], "2.0.0rc1");
assert_eq!(versions[4], "2.0.0b1");
assert_eq!(versions[5], "2.0.0a1");
// Versions with "b" should NOT be considered nightly for Zen Browser
assert!(!is_zen_nightly_version("1.12.8b"));
assert!(!is_zen_nightly_version("1.0.0b1"));
assert!(!is_zen_nightly_version("2.0.0"));
}
#[tokio::test]
@@ -1503,7 +1709,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -1519,7 +1724,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("invalid json")
@@ -1539,7 +1743,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
.mount(&server)
.await;
+109 -85
View File
@@ -6,6 +6,8 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::Emitter;
use crate::extraction::Extractor;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppReleaseAsset {
pub name: String,
@@ -47,8 +49,19 @@ impl AppAutoUpdater {
/// Check if running a nightly build based on environment variable
pub fn is_nightly_build() -> bool {
// If STABLE_RELEASE env var is set at compile time, it's a stable build
// Otherwise, it's a nightly build
option_env!("STABLE_RELEASE").is_none()
if option_env!("STABLE_RELEASE").is_some() {
return false;
}
// Also check if the current version starts with "nightly-"
let current_version = Self::get_current_version();
if current_version.starts_with("nightly-") {
return true;
}
// If STABLE_RELEASE is not set and version doesn't start with "nightly-",
// it's still considered a nightly build (dev builds, main branch builds, etc.)
true
}
/// Get current app version from build-time injection
@@ -143,7 +156,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -157,6 +170,10 @@ impl AppAutoUpdater {
/// Determine if an update should be performed
fn should_update(&self, current_version: &str, new_version: &str, is_nightly: bool) -> bool {
if current_version.starts_with("dev-") {
return false;
}
println!(
"Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}"
);
@@ -210,6 +227,7 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
// Priority 1: Get architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
@@ -218,12 +236,9 @@ impl AppAutoUpdater {
"unknown"
};
println!("Looking for assets with architecture: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
println!("Falling back to architecture-specific search for: {arch}");
// Priority 1: Look for exact architecture match in DMG
// Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg"))
@@ -236,7 +251,7 @@ impl AppAutoUpdater {
}
}
// Priority 2: Look for x86_64 variations if we're looking for x64
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -248,7 +263,7 @@ impl AppAutoUpdater {
}
}
// Priority 3: Look for arm64 variations if we're looking for aarch64
// Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -260,7 +275,7 @@ impl AppAutoUpdater {
}
}
// Priority 4: Fallback to any macOS DMG
// Priority 2: Fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
@@ -339,7 +354,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -359,73 +374,33 @@ impl AppAutoUpdater {
Ok(file_path)
}
/// Extract the update (DMG on macOS)
/// Extract the update using the extraction module
async fn extract_update(
&self,
dmg_path: &Path,
archive_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For DMG files on macOS, we need to mount and copy the .app
let mount_point = dest_dir.join("mount");
fs::create_dir_all(&mount_point)?;
let extractor = Extractor::new();
// Mount the DMG
let output = Command::new("hdiutil")
.args([
"attach",
"-nobrowse",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
if !output.status.success() {
return Err(
format!(
"Failed to mount DMG: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
match extension {
"dmg" => {
#[cfg(target_os = "macos")]
{
extractor.extract_dmg(archive_path, dest_dir).await
}
#[cfg(not(target_os = "macos"))]
{
Err("DMG extraction is only supported on macOS".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
// Find the .app in the mount point
let app_entry = fs::read_dir(&mount_point)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("No .app found in DMG")?;
let app_path = dest_dir.join("extracted_app");
if app_path.exists() {
fs::remove_dir_all(&app_path)?;
}
// Copy the .app to extraction directory
let output = Command::new("cp")
.args([
"-R",
app_entry.path().to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to copy app: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Unmount the DMG
let _ = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output();
Ok(app_path)
}
/// Install the update by replacing the current app
@@ -567,14 +542,6 @@ pub async fn download_and_install_app_update(
.map_err(|e| format!("Failed to install app update: {e}"))
}
#[tauri::command]
pub fn get_app_version_info() -> Result<(String, bool), String> {
Ok((
AppAutoUpdater::get_current_version(),
AppAutoUpdater::is_nightly_build(),
))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
@@ -594,6 +561,10 @@ mod tests {
// This will depend on whether STABLE_RELEASE is set during test compilation
let is_nightly = AppAutoUpdater::is_nightly_build();
println!("Is nightly build: {is_nightly}");
// The result should be true for test builds since STABLE_RELEASE is not set
// unless the test is run in a stable release environment
assert!(is_nightly || option_env!("STABLE_RELEASE").is_some());
}
#[test]
@@ -640,8 +611,10 @@ mod tests {
// Upgrade from stable to nightly
assert!(updater.should_update("v1.0.0", "nightly-abc123", true));
// Upgrade from dev to nightly
assert!(updater.should_update("dev-0.1.0", "nightly-abc123", true));
// Don't upgrade dev, ever
assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", false));
assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", true));
assert!(!updater.should_update("dev-0.1.0", "v1.2.3", false));
}
#[test]
@@ -682,8 +655,59 @@ mod tests {
let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// The exact URL depends on the target architecture
let url = url.unwrap();
assert!(url.contains(".dmg"));
// Test with generic macOS DMG (no architecture specified)
let generic_assets = vec![AppReleaseAsset {
name: "Donut.Browser_0.1.0_macos.dmg".to_string(),
browser_download_url: "https://example.com/macos.dmg".to_string(),
size: 12345,
}];
let generic_url = updater.get_download_url_for_platform(&generic_assets);
assert!(generic_url.is_some());
assert_eq!(generic_url.unwrap(), "https://example.com/macos.dmg");
// Test architecture-specific DMG
let arch_specific_assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let arch_url = updater.get_download_url_for_platform(&arch_specific_assets);
assert!(arch_url.is_some());
// The exact URL depends on the target architecture, but should be one of the available ones
let arch_url = arch_url.unwrap();
assert!(arch_url.contains(".dmg"));
}
#[test]
fn test_extract_update_uses_extractor() {
// This test verifies that the extract_update method properly uses the Extractor
// We can't run the actual extraction in unit tests without real DMG files,
// but we can verify the method signature and basic logic
let updater = AppAutoUpdater::new();
// Test that unsupported formats would be rejected
let temp_dir = std::env::temp_dir();
let unsupported_file = temp_dir.join("test.rar");
// Create a mock runtime to test the logic
let rt = tokio::runtime::Runtime::new().unwrap();
// This would fail because .rar is not supported, which proves
// our method is using the Extractor logic
let result = rt.block_on(async { updater.extract_update(&unsupported_file, &temp_dir).await });
// Should fail with unsupported format error
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Unsupported archive format: rar"));
}
}
+53 -63
View File
@@ -112,7 +112,7 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_stable = !self.is_alpha_version(current_version);
let is_current_stable = !self.is_nightly_version(current_version);
// Find the best available update
let best_update = available_versions
@@ -218,40 +218,6 @@ impl AutoUpdater {
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Start browser update process
pub async fn start_browser_update(
&self,
browser: &str,
new_version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Add browser to disabled list to prevent conflicts during update
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.insert(browser.to_string());
// Mark this download as auto-update for toast suppression
let download_key = format!("{browser}-{new_version}");
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
// The actual download will be triggered by the frontend
// This function now just marks the browser as updating to prevent conflicts
Ok(())
}
/// Complete browser update process
pub async fn complete_browser_update(
&self,
browser: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Remove browser from disabled list
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.remove(browser);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
@@ -312,9 +278,51 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
// Check if auto-delete of unused binaries is enabled and perform cleanup
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background - don't fail the update if cleanup fails
if let Err(e) = self.cleanup_unused_binaries_internal() {
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
}
}
Ok(updated_profiles)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Load current profiles
let profiles = self
.browser_runner
.list_profiles()
.map_err(|e| format!("Failed to load profiles: {e}"))?;
// Load registry
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
// Get active browser versions
let active_versions = registry.get_active_browser_versions(&profiles);
// Cleanup unused binaries
let cleaned_up = registry
.cleanup_unused_binaries(&active_versions)
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
// Save updated registry
registry
.save()
.map_err(|e| format!("Failed to save registry: {e}"))?;
Ok(cleaned_up)
}
/// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled(
&self,
@@ -337,7 +345,7 @@ impl AutoUpdater {
// Helper methods
fn is_alpha_version(&self, version: &str) -> bool {
fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha")
|| version.contains("beta")
|| version.contains("rc")
@@ -414,24 +422,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped)
}
#[tauri::command]
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.start_browser_update(&browser, &new_version)
.await
.map_err(|e| format!("Failed to start browser update: {e}"))
}
#[tauri::command]
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.complete_browser_update(&browser)
.await
.map_err(|e| format!("Failed to complete browser update: {e}"))
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
@@ -509,18 +499,18 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
fn test_is_nightly_version() {
let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev"));
assert!(updater.is_nightly_version("1.0.0-alpha"));
assert!(updater.is_nightly_version("1.0.0-beta"));
assert!(updater.is_nightly_version("1.0.0-rc"));
assert!(updater.is_nightly_version("1.0.0a1"));
assert!(updater.is_nightly_version("1.0.0b1"));
assert!(updater.is_nightly_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3"));
assert!(!updater.is_nightly_version("1.0.0"));
assert!(!updater.is_nightly_version("1.2.3"));
}
#[test]
+526 -136
View File
@@ -50,7 +50,6 @@ impl BrowserType {
}
pub trait Browser: Send + Sync {
fn browser_type(&self) -> BrowserType;
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
&self,
@@ -59,24 +58,17 @@ pub trait Browser: Send + Sync {
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
// Platform-specific modules
#[cfg(target_os = "macos")]
mod macos {
use super::*;
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
pub fn get_firefox_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
@@ -106,6 +98,439 @@ impl Browser for FirefoxBrowser {
Ok(executable_path)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On macOS, no special preparation needed
Ok(())
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::os::unix::fs::PermissionsExt;
pub fn get_firefox_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
// Try firefox first (preferred), then firefox-bin
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
]
}
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("tor-browser"),
browser_subdir.join("firefox-bin"),
]
}
_ => vec![],
};
for executable_path in &possible_executables {
if executable_path.exists() && executable_path.is_file() {
return Ok(executable_path.clone());
}
}
Err(
format!(
"Firefox executable not found in {}/{}",
install_dir.display(),
browser_type.as_str()
)
.into(),
)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
],
_ => vec![],
};
for executable_path in &possible_executables {
if executable_path.exists() && executable_path.is_file() {
return Ok(executable_path.clone());
}
}
Err(
format!(
"Chromium executable not found in {}/{}",
install_dir.display(),
browser_type.as_str()
)
.into(),
)
}
pub fn is_firefox_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
if !browser_subdir.exists() || !browser_subdir.is_dir() {
return false;
}
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
browser_subdir.join("tor-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
if !browser_subdir.exists() || !browser_subdir.is_dir() {
return false;
}
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
],
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
false
}
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
println!("Setting execute permissions for: {:?}", executable_path);
let metadata = std::fs::metadata(executable_path)?;
let mut permissions = metadata.permissions();
// Add execute permissions for owner, group, and others
let mode = permissions.mode();
permissions.set_mode(mode | 0o755);
std::fs::set_permissions(executable_path, permissions)?;
println!(
"Execute permissions set successfully for: {:?}",
executable_path
);
Ok(())
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn get_firefox_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for firefox.exe
let possible_paths = [
install_dir.join("firefox.exe"),
install_dir.join("firefox").join("firefox.exe"),
install_dir.join("bin").join("firefox.exe"),
];
for path in &possible_paths {
if path.exists() && path.is_file() {
return Ok(path.clone());
}
}
// Look for any .exe file that might be the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.contains("browser")
{
return Ok(path);
}
}
}
}
Err("Firefox executable not found in Windows installation directory".into())
}
pub fn get_chromium_executable_path(
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for .exe files
let possible_paths = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
],
_ => vec![],
};
for path in &possible_paths {
if path.exists() && path.is_file() {
return Ok(path.clone());
}
}
// Look for any .exe file that might be the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.contains("chromium") || name.contains("brave") || name.contains("chrome") {
return Ok(path);
}
}
}
}
Err("Chromium/Brave executable not found in Windows installation directory".into())
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
// On Windows, check for .exe files
let possible_executables = [
install_dir.join("firefox.exe"),
install_dir.join("firefox").join("firefox.exe"),
install_dir.join("bin").join("firefox.exe"),
];
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
// Check for any .exe file that looks like a browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.contains("browser")
{
return true;
}
}
}
}
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// On Windows, check for .exe files
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
],
_ => vec![],
};
for exe_path in &possible_executables {
if exe_path.exists() && exe_path.is_file() {
return true;
}
}
// Check for any .exe file that looks like the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.contains("chromium") || name.contains("brave") || name.contains("chrome") {
return true;
}
}
}
}
false
}
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Windows, no special preparation needed
Ok(())
}
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
@@ -135,34 +560,52 @@ impl Browser for FirefoxBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
// Only check if directory exists and contains a .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
return true;
}
}
}
println!("No .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
@@ -173,34 +616,18 @@ impl ChromiumBrowser {
}
impl Browser for ChromiumBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
Ok(executable_path)
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
@@ -240,35 +667,46 @@ impl Browser for ChromiumBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Chromium browser checking version {version} in directory: {browser_dir:?}");
// Check if directory exists and contains at least one .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
// Try to get the executable path as a final verification
if self.get_executable_path(&browser_dir).is_ok() {
println!(" Executable path verification successful");
return true;
} else {
println!(" Executable path verification failed");
}
}
}
}
println!("No valid .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
@@ -294,7 +732,7 @@ pub struct GithubRelease {
#[serde(default)]
pub published_at: String,
#[serde(default)]
pub is_alpha: bool,
pub is_nightly: bool,
#[serde(default)]
pub prerelease: bool,
}
@@ -303,6 +741,8 @@ pub struct GithubRelease {
pub struct GithubAsset {
pub name: String,
pub browser_download_url: String,
#[serde(default)]
pub size: u64,
}
#[cfg(test)]
@@ -352,56 +792,6 @@ mod tests {
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive
}
#[test]
fn test_firefox_browser_creation() {
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = FirefoxBrowser::new(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
}
#[test]
fn test_chromium_browser_creation() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = ChromiumBrowser::new(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_browser_factory() {
// Test Firefox-based browsers
let browser = create_browser(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = create_browser(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = create_browser(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
let browser = create_browser(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = create_browser(BrowserType::FirefoxDeveloper);
assert_eq!(browser.browser_type(), BrowserType::FirefoxDeveloper);
// Test Chromium-based browsers
let browser = create_browser(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = create_browser(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
@@ -507,7 +897,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
@@ -519,7 +909,7 @@ mod tests {
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
// Test with Chromium browser
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).unwrap();
let chromium_app_dir = chromium_dir.join("Chromium.app");
@@ -542,7 +932,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create browser directory but no .app directory
// Create browser directory but no .app directory with new path structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+198 -7
View File
@@ -77,13 +77,139 @@ mod windows {
#[cfg(target_os = "linux")]
mod linux {
use std::process::Command;
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
let schemes = ["http", "https"];
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Query the current default handler for this scheme
let output = Command::new("xdg-mime")
.args(["query", "default", &mime_type])
.output()
.map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr));
}
let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Check if our app is the default handler
if current_handler != APP_DESKTOP_NAME {
return Ok(false);
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
// Check if the desktop file exists in common locations
if !check_desktop_file_exists() {
return Err(format!(
"Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.",
APP_DESKTOP_NAME
));
}
let schemes = ["http", "https"];
let mut all_succeeded = true;
let mut error_messages = Vec::new();
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Set our app as the default handler for this scheme
let output = Command::new("xdg-mime")
.args(["default", APP_DESKTOP_NAME, &mime_type])
.output()
.map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?;
if !output.status.success() {
all_succeeded = false;
let stderr = String::from_utf8_lossy(&output.stderr);
error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr));
}
}
if !all_succeeded {
return Err(format!(
"Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session",
error_messages.join("\n")
));
}
// Give the system a moment to process the changes
std::thread::sleep(std::time::Duration::from_millis(500));
// Verify the changes took effect
match is_default_browser() {
Ok(true) => Ok(()),
Ok(false) => {
// This is the common case where commands succeed but verification fails
Err(format!(
"The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.",
APP_DESKTOP_NAME
))
}
Err(e) => Err(format!(
"Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.",
e
))
}
}
fn is_xdg_mime_available() -> bool {
Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_desktop_file_exists() -> bool {
let desktop_locations = [
"~/.local/share/applications/",
"/usr/share/applications/",
"/usr/local/share/applications/",
"/var/lib/flatpak/exports/share/applications/",
"~/.local/share/flatpak/exports/share/applications/",
];
for location in &desktop_locations {
let path = if location.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
location.replace('~', &home)
} else {
continue;
}
} else {
location.to_string()
};
let full_path = format!("{}{}", path, APP_DESKTOP_NAME);
if std::path::Path::new(&full_path).exists() {
return true;
}
}
false
}
}
@@ -153,8 +279,8 @@ pub async fn open_url_with_profile(
#[tauri::command]
pub async fn smart_open_url(
_app_handle: tauri::AppHandle,
_url: String,
app_handle: tauri::AppHandle,
url: String,
_is_startup: Option<bool>,
) -> Result<String, String> {
use crate::browser_runner::BrowserRunner;
@@ -171,10 +297,75 @@ pub async fn smart_open_url(
}
println!(
"URL opening - Total profiles: {}, showing profile selector",
"URL opening - Total profiles: {}, checking for running profiles",
profiles.len()
);
// Always show the profile selector so the user can choose
// Check for running profiles and find the first one that can handle URLs
for profile in &profiles {
// Check if this profile is running
let is_running = runner
.check_browser_status(app_handle.clone(), profile)
.await
.unwrap_or(false);
if is_running {
println!(
"Found running profile '{}', attempting to open URL",
profile.name
);
// For TOR browser: Check if any other TOR browser is running
if profile.browser == "tor-browser" {
let mut other_tor_running = false;
for p in &profiles {
if p.browser == "tor-browser"
&& p.name != profile.name
&& runner
.check_browser_status(app_handle.clone(), p)
.await
.unwrap_or(false)
{
other_tor_running = true;
break;
}
}
if other_tor_running {
continue; // Skip this one, can't have multiple TOR instances
}
}
// For Mullvad browser: skip if running (can't open URLs in running Mullvad)
if profile.browser == "mullvad-browser" {
continue;
}
// Try to open the URL with this running profile
match runner
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()))
.await
{
Ok(_) => {
println!(
"Successfully opened URL '{}' with running profile '{}'",
url, profile.name
);
return Ok(format!("opened_with_profile:{}", profile.name));
}
Err(e) => {
println!(
"Failed to open URL with running profile '{}': {}",
profile.name, e
);
// Continue to try other profiles or show selector
}
}
}
}
println!("No suitable running profiles found, showing profile selector");
// No suitable running profile found, show the profile selector
Err("show_selector".to_string())
}
+261 -46
View File
@@ -51,7 +51,7 @@ impl Downloader {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
@@ -65,19 +65,20 @@ impl Downloader {
})
.ok_or(format!("Brave version {version} not found"))?;
// Find the universal macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No universal macOS DMG asset found for Brave version {version}"
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists
// For Zen, verify the asset exists and handle different naming patterns
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
@@ -88,16 +89,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Zen version {version} not found"))?;
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS universal asset found for Zen version {version}"
"No compatible asset found for Zen version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
@@ -111,16 +113,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?;
// Find the macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS asset found for Mullvad version {version}"
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
_ => {
// For other browsers, use the provided URL
@@ -129,6 +132,200 @@ impl Downloader {
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
.or_else(|| {
// Fallback to DEB packages
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && name.ends_with(".deb")
})
})
.or_else(|| {
// Fallback to any ZIP
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
})
})
.or_else(|| {
// Fallback to any DEB
assets.iter().find(|asset| asset.name.ends_with(".deb"))
})
.or_else(|| {
// Last fallback to RPM if no ZIP or DEB found
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("x86_64") && name.ends_with(".rpm")
})
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Mullvad asset for the current platform and architecture
fn find_mullvad_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Mullvad asset naming patterns:
// Windows: mullvad-browser-windows-x86_64-VERSION.exe
// macOS: mullvad-browser-macos-VERSION.dmg
// Linux: mullvad-browser-x86_64-VERSION.tar.xz
let asset = match (os, arch) {
("windows", "x64") => assets.iter().find(|asset| {
asset.name.contains("windows")
&& asset.name.contains("x86_64")
&& asset.name.ends_with(".exe")
}),
("windows", "arm64") => {
// Mullvad doesn't support ARM64 on Windows
None
}
("macos", _) => assets
.iter()
.find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")),
("linux", "x64") => assets.iter().find(|asset| {
asset.name.contains("x86_64")
&& asset.name.ends_with(".tar.xz")
&& !asset.name.contains("windows")
}),
("linux", "arm64") => {
// Mullvad doesn't support ARM64 on Linux
None
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
@@ -144,6 +341,10 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check if this is a twilight release for special handling
let is_twilight =
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
// Emit initial progress
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -153,7 +354,11 @@ impl Downloader {
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "downloading".to_string(),
stage: if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
},
};
let _ = app_handle.emit("download-progress", &progress);
@@ -162,7 +367,7 @@ impl Downloader {
let response = self
.client
.get(&download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -205,6 +410,12 @@ impl Downloader {
None
};
let stage_description = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
@@ -213,7 +424,7 @@ impl Downloader {
percentage,
speed_bytes_per_sec: speed,
eta_seconds: eta,
stage: "downloading".to_string(),
stage: stage_description,
};
let _ = app_handle.emit("download-progress", &progress);
@@ -233,7 +444,7 @@ mod tests {
use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -267,7 +478,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
@@ -275,7 +487,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -314,7 +526,8 @@ mod tests {
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg"
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg",
"size": 120000000
}
]
}
@@ -322,7 +535,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -361,7 +574,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
"size": 100000000
}
]
}
@@ -369,7 +583,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -471,7 +685,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.8-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
"size": 200000000
}
]
}
@@ -479,7 +694,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -520,7 +735,8 @@ mod tests {
"assets": [
{
"name": "zen.linux-universal.tar.bz2",
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2"
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2",
"size": 150000000
}
]
}
@@ -528,7 +744,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -551,7 +767,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS universal asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -570,7 +786,6 @@ mod tests {
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content)
@@ -621,7 +836,6 @@ mod tests {
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -663,7 +877,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-linux-14.5a6.tar.xz",
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz"
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz",
"size": 80000000
}
]
}
@@ -671,7 +886,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -694,7 +909,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -712,7 +927,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
@@ -720,7 +936,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -759,7 +975,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content.clone())
+76
View File
@@ -12,6 +12,9 @@ pub struct DownloadedBrowserInfo {
pub file_path: PathBuf,
pub verified: bool,
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
pub file_size: Option<u64>, // For tracking file size changes (useful for rolling releases)
#[serde(default)] // Add default value (false) for backwards compatibility
pub is_rolling_release: bool, // True for Zen's twilight releases and other rolling releases
}
#[derive(Debug, Serialize, Deserialize, Default)]
@@ -98,6 +101,7 @@ impl DownloadedBrowsersRegistry {
}
pub fn mark_download_started(&mut self, browser: &str, version: &str, file_path: PathBuf) {
let is_rolling = Self::is_rolling_release(browser, version);
let info = DownloadedBrowserInfo {
browser: browser.to_string(),
version: version.to_string(),
@@ -108,6 +112,8 @@ impl DownloadedBrowsersRegistry {
file_path,
verified: false,
actual_version: None,
file_size: None,
is_rolling_release: is_rolling,
};
self.add_browser(info);
}
@@ -131,6 +137,11 @@ impl DownloadedBrowsersRegistry {
}
}
fn is_rolling_release(browser: &str, version: &str) -> bool {
// Check if this is a rolling release like twilight
browser == "zen" && version.to_lowercase() == "twilight"
}
pub fn cleanup_failed_download(
&mut self,
browser: &str,
@@ -164,6 +175,48 @@ impl DownloadedBrowsersRegistry {
}
Ok(())
}
/// Find and remove unused browser binaries that are not referenced by any active profiles
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let active_set: std::collections::HashSet<(String, String)> =
active_profiles.iter().cloned().collect();
let mut cleaned_up = Vec::new();
// Collect all downloaded browsers that are not in active profiles
let mut to_remove = Vec::new();
for (browser, versions) in &self.browsers {
for (version, info) in versions {
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
to_remove.push((browser.clone(), version.clone()));
}
}
}
// Remove unused binaries
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
} else {
cleaned_up.push(format!("{browser} {version}"));
}
}
Ok(cleaned_up)
}
/// Get all browsers and versions referenced by active profiles
pub fn get_active_browser_versions(
&self,
profiles: &[crate::browser_runner::BrowserProfile],
) -> Vec<(String, String)> {
profiles
.iter()
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
}
}
#[cfg(test)]
@@ -186,6 +239,8 @@ mod tests {
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info.clone());
@@ -206,6 +261,8 @@ mod tests {
file_path: PathBuf::from("/test/path1"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
let info2 = DownloadedBrowserInfo {
@@ -215,6 +272,8 @@ mod tests {
file_path: PathBuf::from("/test/path2"),
verified: false, // Not verified, should not be included
actual_version: None,
file_size: None,
is_rolling_release: false,
};
let info3 = DownloadedBrowserInfo {
@@ -224,6 +283,8 @@ mod tests {
file_path: PathBuf::from("/test/path3"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info1);
@@ -266,6 +327,8 @@ mod tests {
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info);
@@ -275,4 +338,17 @@ mod tests {
assert!(removed.is_some());
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
}
#[test]
fn test_twilight_rolling_release() {
let mut registry = DownloadedBrowsersRegistry::new();
// Mark twilight download started
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
// Check that it's marked as rolling release
let zen_versions = &registry.browsers["zen"];
let twilight_info = &zen_versions["twilight"];
assert!(twilight_info.is_rolling_release);
}
}
+707 -30
View File
@@ -34,19 +34,147 @@ impl Extractor {
};
let _ = app_handle.emit("download-progress", &progress);
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// Try to detect the actual file type by reading the file header
let actual_format = self.detect_file_format(archive_path)?;
match extension {
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
match actual_format.as_str() {
"dmg" => {
#[cfg(target_os = "macos")]
return self.extract_dmg(archive_path, dest_dir).await;
#[cfg(not(target_os = "macos"))]
return Err("DMG extraction is only supported on macOS".into());
}
"zip" => self.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
"tar.xz" => self.extract_tar_xz(archive_path, dest_dir).await,
"tar.bz2" => self.extract_tar_bz2(archive_path, dest_dir).await,
"tar.gz" => self.extract_tar_gz(archive_path, dest_dir).await,
"exe" => {
// For Windows EXE files, some may be self-extracting archives, others are installers
// For browsers like Firefox, TOR, they're typically installers that don't need extraction
self
.handle_exe_file(archive_path, dest_dir, browser_type)
.await
}
"deb" => {
#[cfg(target_os = "linux")]
return self.extract_deb(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("DEB extraction is only supported on Linux".into());
}
"appimage" => {
#[cfg(target_os = "linux")]
return self.handle_appimage(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("AppImage is only supported on Linux".into());
}
_ => {
Err(format!(
"Unsupported archive format: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format.",
archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"),
actual_format
).into())
}
}
}
async fn extract_dmg(
/// Detect the actual file format by reading file headers
fn detect_file_format(
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use std::fs::File;
use std::io::Read;
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
file.read_exact(&mut buffer)?;
// Check magic numbers for different file types
match &buffer[0..4] {
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
return Ok("zip".to_string())
}
[0x7F, 0x45, 0x4C, 0x46] => return Ok("appimage".to_string()), // ELF header (AppImage)
[0x4D, 0x5A, _, _] => return Ok("exe".to_string()), // PE header (Windows EXE)
_ => {}
}
// Check for XZ compressed files
if buffer[0..6] == [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] {
return Ok("tar.xz".to_string());
}
// Check for Bzip2 compressed files
if buffer[0..3] == [0x42, 0x5A, 0x68] {
return Ok("tar.bz2".to_string());
}
// Check for Gzip compressed files
if buffer[0..3] == [0x1F, 0x8B, 0x08] {
return Ok("tar.gz".to_string());
}
// Check for DEB files
if buffer[0..8] == [0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A] {
return Ok("deb".to_string());
}
// Fallback to file extension
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
match ext.to_lowercase().as_str() {
"dmg" => Ok("dmg".to_string()),
"zip" => Ok("zip".to_string()),
"xz" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.xz")
{
Ok("tar.xz".to_string())
} else {
Ok("xz".to_string())
}
}
"bz2" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.bz2")
{
Ok("tar.bz2".to_string())
} else {
Ok("bz2".to_string())
}
}
"gz" => {
if file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".tar.gz")
{
Ok("tar.gz".to_string())
} else {
Ok("gz".to_string())
}
}
"exe" => Ok("exe".to_string()),
"deb" => Ok("deb".to_string()),
"appimage" => Ok("appimage".to_string()),
_ => Ok("unknown".to_string()),
}
} else {
Ok("unknown".to_string())
}
}
#[cfg(target_os = "macos")]
pub async fn extract_dmg(
&self,
dmg_path: &Path,
dest_dir: &Path,
@@ -149,12 +277,61 @@ impl Extractor {
Ok(app_path)
}
async fn extract_zip(
pub async fn extract_zip(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command to extract
// Platform-specific ZIP extraction
#[cfg(target_os = "windows")]
{
self.extract_zip_windows(zip_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
self.extract_zip_unix(zip_path, dest_dir).await
}
}
#[cfg(target_os = "windows")]
async fn extract_zip_windows(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use PowerShell's Expand-Archive on Windows
let output = Command::new("powershell")
.args([
"-Command",
&format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
zip_path.display(),
dest_dir.display()
),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract zip with PowerShell: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
self.find_extracted_executable(dest_dir).await
}
#[cfg(not(target_os = "windows"))]
async fn extract_zip_unix(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command on Unix-like systems
let output = Command::new("unzip")
.args([
"-q", // quiet
@@ -174,16 +351,269 @@ impl Extractor {
);
}
// Find the extracted .app directory or Chromium.app specifically
let mut app_path: Option<PathBuf> = None;
self.find_extracted_executable(dest_dir).await
}
pub async fn extract_tar_xz(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.xz: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
pub async fn extract_tar_bz2(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xjf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.bz2: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
pub async fn extract_tar_gz(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Use tar command for more reliable extraction
let output = Command::new("tar")
.args([
"-xzf",
tar_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract tar.gz: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly for Linux
if cfg!(target_os = "linux") {
self.set_executable_permissions(&executable_path).await?;
}
Ok(executable_path)
}
#[cfg(target_os = "linux")]
pub async fn extract_deb(
&self,
deb_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// Extract DEB package using dpkg-deb
let output = Command::new("dpkg-deb")
.args(["-x", deb_path.to_str().unwrap(), dest_dir.to_str().unwrap()])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract DEB: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted executable and set proper permissions
let executable_path = self.find_extracted_executable(dest_dir).await?;
// Ensure executable permissions are set correctly
self.set_executable_permissions(&executable_path).await?;
Ok(executable_path)
}
#[cfg(target_os = "linux")]
pub async fn handle_appimage(
&self,
appimage_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
create_dir_all(dest_dir)?;
// For AppImages, we typically just copy them and make sure they're executable
let dest_file = dest_dir.join(
appimage_path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("app.AppImage")),
);
// Copy the AppImage to destination
fs::copy(appimage_path, &dest_file)?;
// Set executable permissions
self.set_executable_permissions(&dest_file).await?;
Ok(dest_file)
}
pub async fn handle_exe_file(
&self,
exe_path: &Path,
dest_dir: &Path,
browser_type: BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Zen => {
// Zen installer EXE needs to be run to install
#[cfg(target_os = "windows")]
{
self.install_zen_windows(exe_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
Err("Zen EXE installation is only supported on Windows".into())
}
}
_ => {
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
}
}
#[cfg(target_os = "windows")]
async fn install_zen_windows(
&self,
installer_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For Zen installer, we need to run it silently
// This is a simplified approach - in practice, you might need more sophisticated installer handling
let output = Command::new(installer_path)
.args(["/S", &format!("/D={}", dest_dir.display())])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
}
async fn find_extracted_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Platform-specific executable finding logic
#[cfg(target_os = "macos")]
{
self.find_macos_app(dest_dir).await
}
#[cfg(target_os = "windows")]
{
self.find_windows_executable(dest_dir).await
}
#[cfg(target_os = "linux")]
{
self.find_linux_executable(dest_dir).await
}
}
#[cfg(target_os = "macos")]
async fn find_macos_app(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// First, try to find any .app file in the destination directory
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "app") {
app_path = Some(path);
break;
return Ok(path);
}
// For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() {
@@ -194,33 +624,280 @@ impl Extractor {
// Move the app to the root destination directory
let target_path = dest_dir.join(sub_path.file_name().unwrap());
fs::rename(&sub_path, &target_path)?;
app_path = Some(target_path);
// Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path);
break;
return Ok(target_path);
}
}
if app_path.is_some() {
break;
}
}
}
}
}
let app_path = app_path.ok_or("No .app found after extraction")?;
Err("No .app found after extraction".into())
}
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
#[cfg(target_os = "windows")]
async fn find_windows_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Look for .exe files, preferring main browser executables
let exe_names = [
"chrome.exe",
"firefox.exe",
"zen.exe",
"brave.exe",
"tor.exe",
];
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
for exe_name in &exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() {
return Ok(exe_path);
}
}
Ok(app_path)
// If no specific executable found, look for any .exe file
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
return Ok(path);
}
// Check subdirectories
if path.is_dir() {
if let Ok(sub_result) = self.find_windows_executable(&path).await {
return Ok(sub_result);
}
}
}
}
Err("No executable found after extraction".into())
}
#[cfg(target_os = "linux")]
async fn find_linux_executable(
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Enhanced list of common browser executable names with better pattern matching
let exe_names = [
// Firefox variants
"firefox",
"firefox-bin",
"firefox-esr",
"firefox-trunk",
// Chrome/Chromium variants
"chrome",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium-bin",
// Zen Browser
"zen",
"zen-browser",
"zen-bin",
// Brave variants
"brave",
"brave-browser",
"brave-browser-stable",
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Tor Browser variants
"tor-browser",
"torbrowser-launcher",
"tor-browser_en-US",
"start-tor-browser",
"Browser/start-tor-browser",
// Mullvad Browser
"mullvad-browser",
"mullvad-browser-bin",
// AppImage pattern (will be handled specially)
"*.AppImage",
];
// First, try direct lookup in the main directory
for exe_name in &exe_names {
if exe_name.contains('*') {
// Handle glob patterns like *.AppImage
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
return Ok(path);
}
}
}
}
} else {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
return Ok(exe_path);
}
}
}
// Enhanced list of common Linux subdirectories to search
let subdirs = [
// Standard Unix directories
"bin",
"usr/bin",
"usr/local/bin",
"opt",
"sbin",
"usr/sbin",
// Browser-specific directories
"firefox",
"chrome",
"chromium",
"brave",
"zen",
"tor-browser",
"mullvad-browser",
// Common extraction patterns
".",
"./",
// Package-specific extraction patterns
"firefox",
"mullvad-browser",
"tor-browser_en-US",
"Browser",
"browser",
// Nested patterns for different distro packaging
"opt/google/chrome",
"opt/brave.com/brave",
"opt/mullvad-browser",
"usr/lib/firefox",
"usr/lib/chromium",
"usr/share/applications",
// AppImage mount patterns
"usr/bin",
"AppRun",
];
// Search in subdirectories with better depth handling
for subdir in &subdirs {
let subdir_path = dest_dir.join(subdir);
if subdir_path.exists() && subdir_path.is_dir() {
for exe_name in &exe_names {
if exe_name.contains('*') {
// Handle glob patterns for AppImages
if let Ok(entries) = fs::read_dir(&subdir_path) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
return Ok(path);
}
}
}
}
} else {
let exe_path = subdir_path.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
return Ok(exe_path);
}
}
}
}
}
// Last resort: enhanced recursive search for any executable file
self.find_any_executable_recursive(dest_dir, 0).await
}
#[cfg(target_os = "linux")]
fn is_executable(&self, path: &Path) -> bool {
if let Ok(metadata) = path.metadata() {
use std::os::unix::fs::PermissionsExt;
return metadata.permissions().mode() & 0o111 != 0;
}
false
}
/// Set executable permissions on Linux for extracted binaries
#[cfg(target_os = "linux")]
async fn set_executable_permissions(
&self,
path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::os::unix::fs::PermissionsExt;
if path.exists() {
let mut permissions = path.metadata()?.permissions();
// Set executable permissions for owner, group, and others if they have read permission
let current_mode = permissions.mode();
let new_mode = current_mode | 0o111; // Add execute permission
permissions.set_mode(new_mode);
std::fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
async fn set_executable_permissions(
&self,
_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn find_any_executable_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Limit recursion depth to avoid infinite loops
if depth > 5 {
return Err("Maximum search depth reached".into());
}
if let Ok(entries) = fs::read_dir(dir) {
let mut directories = Vec::new();
// First pass: look for executable files
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && self.is_executable(&path) {
// Prefer files with browser-like names
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
let name_lower = file_name.to_lowercase();
if name_lower.contains("firefox")
|| name_lower.contains("chrome")
|| name_lower.contains("brave")
|| name_lower.contains("zen")
|| name_lower.contains("tor")
|| name_lower.contains("mullvad")
|| file_name.ends_with(".AppImage")
{
return Ok(path);
}
}
} else if path.is_dir() {
directories.push(path);
}
}
// Second pass: recursively search directories
for dir_path in directories {
if let Ok(result) = Box::pin(self.find_any_executable_recursive(&dir_path, depth + 1)).await
{
return Ok(result);
}
}
}
Err("No executable found".into())
}
}
@@ -232,13 +909,13 @@ mod tests {
#[test]
fn test_extractor_creation() {
let _extractor = Extractor::new();
let _ = Extractor::new();
// Just verify we can create an extractor instance
}
#[test]
fn test_unsupported_archive_format() {
let _extractor = Extractor::new();
let _ = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap();
+248 -37
View File
@@ -1,7 +1,6 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
// Store pending URLs that need to be handled when the window is ready
@@ -17,25 +16,26 @@ mod default_browser;
mod download;
mod downloaded_browsers;
mod extraction;
mod profile_importer;
mod proxy_manager;
mod settings_manager;
mod theme_detector;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed,
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers,
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles,
rename_profile, should_update_browser_cache, update_profile_proxy, update_profile_version,
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,
};
use settings_manager::{
disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings,
save_table_sorting_settings, should_show_settings_on_startup,
clear_all_version_cache_and_refetch, get_app_settings, get_table_sorting_settings,
save_app_settings, save_table_sorting_settings, should_show_settings_on_startup,
};
use default_browser::{
@@ -43,26 +43,66 @@ use default_browser::{
};
use version_updater::{
check_version_update_needed, force_version_update_check, get_version_update_status,
get_version_updater, trigger_manual_version_update,
get_version_update_status, get_version_updater, trigger_manual_version_update,
};
use auto_updater::{
check_for_browser_updates, complete_browser_update, 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, start_browser_update,
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,
};
use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
#[tauri::command]
fn greet() -> String {
let now = SystemTime::now();
let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
format!("Hello world from Rust! Current epoch: {epoch_ms}")
use profile_importer::{detect_existing_profiles, import_browser_profile};
use theme_detector::get_system_theme;
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
}
impl<R: Runtime> WindowExt for WebviewWindow<R> {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String> {
use objc2::rc::Retained;
use objc2_app_kit::{NSWindow, NSWindowStyleMask, NSWindowTitleVisibility};
unsafe {
let ns_window: Retained<NSWindow> =
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
if transparent {
// Hide the title text
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
// Make titlebar transparent
ns_window.setTitlebarAppearsTransparent(true);
// Set full size content view
let current_mask = ns_window.styleMask();
let new_mask = NSWindowStyleMask(current_mask.0 | (1 << 15)); // NSFullSizeContentViewWindowMask
ns_window.setStyleMask(new_mask);
} else {
// Show the title text
ns_window.setTitleVisibility(NSWindowTitleVisibility(0)); // NSWindowTitleVisible
// Make titlebar opaque
ns_window.setTitlebarAppearsTransparent(false);
// Remove full size content view
let current_mask = ns_window.styleMask();
let new_mask = NSWindowStyleMask(current_mask.0 & !(1 << 15));
ns_window.setStyleMask(new_mask);
}
}
Ok(())
}
}
#[tauri::command]
@@ -131,7 +171,27 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
if let Err(e) = window.set_transparent_titlebar(true) {
eprintln!("Failed to set transparent titlebar: {e}");
}
}
// Set up deep link handler
let handle = app.handle().clone();
@@ -211,25 +271,18 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
delete_profile,
is_browser_downloaded,
check_browser_exists,
create_browser_profile_new,
create_browser_profile,
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count,
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions,
get_saved_mullvad_releases,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -238,22 +291,17 @@ pub fn run() {
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
check_for_browser_updates,
start_browser_update,
complete_browser_update,
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
@@ -263,8 +311,171 @@ pub fn run() {
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
get_app_version_info,
get_system_theme,
detect_existing_profiles,
import_browser_profile,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use std::fs;
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
}
#[test]
fn test_unused_tauri_commands_detailed() {
check_unused_commands(true); // Run in verbose mode for development
}
fn check_unused_commands(verbose: bool) {
// Extract command names from the generate_handler! macro in this file
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
let commands = extract_tauri_commands(&lib_rs_content);
// Get all frontend files
let frontend_files = get_frontend_files("../src");
// Check which commands are actually used
let mut unused_commands = Vec::new();
let mut used_commands = Vec::new();
for command in &commands {
let mut is_used = false;
for file_content in &frontend_files {
// More comprehensive search for command usage
if is_command_used(file_content, command) {
is_used = true;
break;
}
}
if is_used {
used_commands.push(command.clone());
if verbose {
println!("{command}");
}
} else {
unused_commands.push(command.clone());
if verbose {
println!("{command} (UNUSED)");
}
}
}
if verbose {
println!("\n📊 Summary:");
println!(" ✅ Used commands: {}", used_commands.len());
println!(" ❌ Unused commands: {}", unused_commands.len());
}
if !unused_commands.is_empty() {
let message = format!(
"Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.",
unused_commands.len(),
unused_commands.join(", ")
);
if verbose {
println!("\n🚨 {message}");
} else {
panic!("{}", message);
}
} else if verbose {
println!("\n🎉 All exported commands are being used!");
} else {
println!(
"✅ All {} exported Tauri commands are being used in the frontend",
commands.len()
);
}
}
fn is_command_used(content: &str, command: &str) -> bool {
// Check various patterns for invoke usage
let patterns = vec![
format!("invoke<{}>(\"{}\"", "", command), // invoke<Type>("command"
format!("invoke(\"{}\"", command), // invoke("command"
format!("invoke<{}>(\"{}\",", "", command), // invoke<Type>("command",
format!("invoke(\"{}\",", command), // invoke("command",
format!("\"{}\"", command), // Just the command name in quotes
];
for pattern in patterns {
if content.contains(&pattern) {
return true;
}
}
// Also check for the command name appearing after "invoke" within a reasonable distance
if let Some(invoke_pos) = content.find("invoke") {
let after_invoke = &content[invoke_pos..];
if let Some(cmd_pos) = after_invoke.find(&format!("\"{command}\"")) {
// If the command appears within 100 characters of "invoke", consider it used
if cmd_pos < 100 {
return true;
}
}
}
false
}
fn extract_tauri_commands(content: &str) -> Vec<String> {
let mut commands = Vec::new();
// Find the generate_handler! macro
if let Some(start) = content.find("tauri::generate_handler![") {
if let Some(end) = content[start..].find("])") {
let handler_content = &content[start + 25..start + end]; // Skip "tauri::generate_handler!["
// Extract command names
for line in handler_content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with("//") {
// Remove trailing comma and whitespace
let command = line.trim_end_matches(',').trim();
if !command.is_empty() {
commands.push(command.to_string());
}
}
}
}
}
commands
}
fn get_frontend_files(src_dir: &str) -> Vec<String> {
let mut files_content = Vec::new();
if let Ok(entries) = fs::read_dir(src_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Recursively read subdirectories
let subdir_files = get_frontend_files(&path.to_string_lossy());
files_content.extend(subdir_files);
} else if let Some(extension) = path.extension() {
if matches!(
extension.to_str(),
Some("ts") | Some("tsx") | Some("js") | Some("jsx")
) {
if let Ok(content) = fs::read_to_string(&path) {
files_content.push(content);
}
}
}
}
}
files_content
}
}
+661
View File
@@ -0,0 +1,661 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::browser_runner::BrowserRunner;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
pub browser: String,
pub name: String,
pub path: String,
pub description: String,
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
browser_runner: BrowserRunner,
}
impl ProfileImporter {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
browser_runner: BrowserRunner::new(),
}
}
/// Detect existing browser profiles on the system
pub fn detect_existing_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
// Detect Firefox profiles
detected_profiles.extend(self.detect_firefox_profiles()?);
// Detect Chrome profiles
detected_profiles.extend(self.detect_chrome_profiles()?);
// Detect Brave profiles
detected_profiles.extend(self.detect_brave_profiles()?);
// Detect Firefox Developer Edition profiles
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
// Detect Chromium profiles
detected_profiles.extend(self.detect_chromium_profiles()?);
// Detect Mullvad Browser profiles
detected_profiles.extend(self.detect_mullvad_browser_profiles()?);
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
.into_iter()
.filter(|profile| seen_paths.insert(profile.path.clone()))
.collect();
Ok(unique_profiles)
}
/// Detect Firefox profiles
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
#[cfg(target_os = "windows")]
{
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")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
Ok(profiles)
}
/// Detect Firefox Developer Edition profiles
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
// Firefox Developer Edition on macOS uses separate profile directories
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
}
#[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")?);
}
}
}
#[cfg(target_os = "linux")]
{
// Firefox Developer Edition on Linux uses separate directories
let firefox_dev_dir = self
.base_dirs
.home_dir()
.join(".mozilla/firefox-dev-edition");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
Ok(profiles)
}
/// Detect Chrome profiles
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chrome_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Google/Chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[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")?);
}
}
#[cfg(target_os = "linux")]
{
let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
Ok(profiles)
}
/// Detect Chromium profiles
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chromium_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[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")?);
}
}
#[cfg(target_os = "linux")]
{
let chromium_dir = self.base_dirs.home_dir().join(".config/chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
Ok(profiles)
}
/// Detect Brave profiles
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[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")?);
}
}
#[cfg(target_os = "linux")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join(".config/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
Ok(profiles)
}
/// Detect Mullvad Browser profiles
fn detect_mullvad_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let mullvad_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
}
#[cfg(target_os = "windows")]
{
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")?);
}
}
#[cfg(target_os = "linux")]
{
let mullvad_dir = self.base_dirs.home_dir().join(".mullvad-browser");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
}
Ok(profiles)
}
/// Detect Zen Browser profiles
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let zen_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[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")?);
}
}
#[cfg(target_os = "linux")]
{
let zen_dir = self.base_dirs.home_dir().join(".zen");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
Ok(profiles)
}
/// Scan Firefox-style profiles directory
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !profiles_dir.exists() {
return Ok(profiles);
}
// Read profiles.ini file if it exists
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
.join("profiles.ini");
if profiles_ini.exists() {
if let Ok(content) = fs::read_to_string(&profiles_ini) {
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
}
}
// Also scan directory for any profile folders not in profiles.ini
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let prefs_file = path.join("prefs.js");
if prefs_file.exists() {
let profile_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
// Check if this profile was already found in profiles.ini
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
profile_name
),
path: path.to_string_lossy().to_string(),
description: format!("Profile folder: {profile_name}"),
});
}
}
}
}
}
Ok(profiles)
}
/// Parse Firefox profiles.ini file
fn parse_firefox_profiles_ini(
&self,
content: &str,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
let mut current_section = String::new();
let mut profile_name = String::new();
let mut profile_path = String::new();
let mut is_relative = true;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
// Save previous profile if complete
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
// Start new section
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
is_relative = true;
} else if line.contains('=') {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"Name" => profile_name = value.to_string(),
"Path" => profile_path = value.to_string(),
"IsRelative" => is_relative = value == "1",
_ => {}
}
}
}
}
// Handle last profile
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
Ok(profiles)
}
/// Scan Chrome-style profiles directory
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !browser_dir.exists() {
return Ok(profiles);
}
// Check for Default profile
let default_profile = browser_dir.join("Default");
if default_profile.exists() && default_profile.join("Preferences").exists() {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(browser_type)
),
path: default_profile.to_string_lossy().to_string(),
description: "Default profile".to_string(),
});
}
// Check for Profile X directories
if let Ok(entries) = fs::read_dir(browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} - Profile {}",
self.get_browser_display_name(browser_type),
profile_number
),
path: path.to_string_lossy().to_string(),
description: format!("Profile {profile_number}"),
});
}
}
}
}
Ok(profiles)
}
/// Get browser display name
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"firefox" => "Firefox",
"firefox-developer" => "Firefox Developer",
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"mullvad-browser" => "Mullvad Browser",
"zen" => "Zen Browser",
"tor-browser" => "Tor Browser",
_ => "Unknown Browser",
}
}
/// Import a profile from an existing browser profile
pub fn import_profile(
&self,
source_path: &str,
browser_type: &str,
new_profile_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err("Source profile path does not exist".into());
}
// Validate browser type
let _browser_type = BrowserType::from_str(browser_type)
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
// Check if a profile with this name already exists
let existing_profiles = self.browser_runner.list_profiles()?;
if existing_profiles
.iter()
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
{
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// Create the new profile directory
let snake_case_name = new_profile_name.to_lowercase().replace(' ', "_");
let profiles_dir = self.browser_runner.get_profiles_dir();
let new_profile_path = profiles_dir.join(&snake_case_name);
create_dir_all(&new_profile_path)?;
// Copy all files from source to destination
Self::copy_directory_recursive(source_path, &new_profile_path)?;
// Create the profile metadata without overwriting the imported data
// We need to find a suitable version for this browser type
let available_versions = self.get_default_version_for_browser(browser_type)?;
let profile = crate::browser_runner::BrowserProfile {
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
profile_path: new_profile_path.to_string_lossy().to_string(),
proxy: None,
process_id: None,
last_launch: None,
};
// Save the profile metadata
self.browser_runner.save_profile(&profile)?;
println!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
);
Ok(())
}
/// Get a default version for a browser type
fn get_default_version_for_browser(
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Try to get a downloaded version first, fallback to a reasonable default
let registry =
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
let downloaded_versions = registry.get_downloaded_versions(browser_type);
if let Some(version) = downloaded_versions.first() {
return Ok(version.clone());
}
// If no downloaded versions, return a sensible default
match browser_type {
"firefox" => Ok("latest".to_string()),
"firefox-developer" => Ok("latest".to_string()),
"chromium" => Ok("latest".to_string()),
"brave" => Ok("latest".to_string()),
"zen" => Ok("latest".to_string()),
"mullvad-browser" => Ok("13.5.16".to_string()), // Mullvad Browser common version
"tor-browser" => Ok("latest".to_string()),
_ => Ok("latest".to_string()),
}
}
/// Recursively copy directory contents
fn copy_directory_recursive(
source: &Path,
destination: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
if !destination.exists() {
create_dir_all(destination)?;
}
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let dest_path = destination.join(entry.file_name());
if source_path.is_dir() {
Self::copy_directory_recursive(&source_path, &dest_path)?;
} else {
fs::copy(&source_path, &dest_path)?;
}
}
Ok(())
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::new();
importer
.detect_existing_profiles()
.map_err(|e| format!("Failed to detect existing profiles: {e}"))
}
#[tauri::command]
pub async fn import_browser_profile(
source_path: String,
browser_type: String,
new_profile_name: String,
) -> Result<(), String> {
let importer = ProfileImporter::new();
importer
.import_profile(&source_path, &browser_type, &new_profile_name)
.map_err(|e| format!("Failed to import profile: {e}"))
}
+40 -15
View File
@@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::browser_version_service::BrowserVersionService;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -28,6 +31,8 @@ pub struct AppSettings {
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")]
pub auto_delete_unused_binaries: bool,
}
fn default_show_settings_on_startup() -> bool {
@@ -42,6 +47,10 @@ fn default_auto_updates_enabled() -> bool {
true
}
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -49,6 +58,7 @@ impl Default for AppSettings {
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(),
}
}
}
@@ -163,13 +173,6 @@ impl SettingsManager {
// 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
}
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
}
#[tauri::command]
@@ -196,14 +199,6 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
.map_err(|e| format!("Failed to check prompt setting: {e}"))
}
#[tauri::command]
pub async fn disable_default_browser_prompt() -> Result<(), String> {
let manager = SettingsManager::new();
manager
.disable_default_browser_prompt()
.map_err(|e| format!("Failed to disable prompt: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new();
@@ -219,3 +214,33 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
}
#[tauri::command]
pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
let api_client = ApiClient::new();
// Clear all cache first
api_client
.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();
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}");
}
});
}
Ok(())
}
+539
View File
@@ -0,0 +1,539 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTheme {
pub theme: String, // "light", "dark", or "unknown"
}
pub struct ThemeDetector;
impl ThemeDetector {
pub fn new() -> Self {
Self
}
/// Detect the system theme preference
pub fn detect_system_theme(&self) -> SystemTheme {
#[cfg(target_os = "linux")]
return linux::detect_system_theme();
#[cfg(target_os = "macos")]
return macos::detect_system_theme();
#[cfg(target_os = "windows")]
return windows::detect_system_theme();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
return SystemTheme {
theme: "unknown".to_string(),
};
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Try multiple methods in order of preference
// 1. Try GNOME/GTK settings via gsettings
if let Ok(theme) = detect_gnome_theme() {
return SystemTheme { theme };
}
// 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6
if let Ok(theme) = detect_kde_theme() {
return SystemTheme { theme };
}
// 3. Try XFCE settings via xfconf-query
if let Ok(theme) = detect_xfce_theme() {
return SystemTheme { theme };
}
// 4. Try looking at current GTK theme name
if let Ok(theme) = detect_gtk_theme() {
return SystemTheme { theme };
}
// 5. Try dconf directly (fallback for GNOME-based systems)
if let Ok(theme) = detect_dconf_theme() {
return SystemTheme { theme };
}
// 6. Try environment variables
if let Ok(theme) = detect_env_theme() {
return SystemTheme { theme };
}
// 7. Try freedesktop portal
if let Ok(theme) = detect_portal_theme() {
return SystemTheme { theme };
}
// 8. Try looking at system color scheme files
if let Ok(theme) = detect_system_files_theme() {
return SystemTheme { theme };
}
// Fallback to unknown
SystemTheme {
theme: "unknown".to_string(),
}
}
fn detect_gnome_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if gsettings is available
if !is_command_available("gsettings") {
return Err("gsettings not available".into());
}
// Try GNOME color scheme first (modern way)
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Fallback to GTK theme name detection
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect GNOME theme".into())
}
fn detect_kde_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try KDE Plasma 6 first
if is_command_available("kreadconfig6") {
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
// Try color scheme as well
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"General",
"--key",
"ColorScheme",
])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if scheme.contains("dark") || scheme.contains("breezedark") {
return Ok("dark".to_string());
} else if scheme.contains("light") || scheme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
// Try KDE Plasma 5 as fallback
if is_command_available("kreadconfig5") {
if let Ok(output) = Command::new("kreadconfig5")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
Err("Could not detect KDE theme".into())
}
fn detect_xfce_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("xfconf-query") {
return Err("xfconf-query not available".into());
}
// Check XFCE theme
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xsettings", "-p", "/Net/ThemeName"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
// Check XFCE window manager theme as backup
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xfwm4", "-p", "/general/theme"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
Err("Could not detect XFCE theme".into())
}
fn detect_gtk_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try to read GTK3 settings file
if let Ok(home) = std::env::var("HOME") {
let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini");
if gtk3_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk3_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
// Try GTK4 settings
let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini");
if gtk4_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk4_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
}
Err("Could not detect GTK theme".into())
}
fn detect_dconf_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("dconf") {
return Err("dconf not available".into());
}
// Try reading color scheme directly from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Try reading GTK theme from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect dconf theme".into())
}
fn detect_env_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check common environment variables
if let Ok(theme) = std::env::var("GTK_THEME") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
Err("Could not detect theme from environment".into())
}
fn detect_portal_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("busctl") {
return Err("busctl not available".into());
}
// Try to query the color scheme via org.freedesktop.portal.Settings
if let Ok(output) = Command::new("busctl")
.args([
"--user",
"call",
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
"ss",
"org.freedesktop.appearance",
"color-scheme",
])
.output()
{
if output.status.success() {
let response = String::from_utf8_lossy(&output.stdout);
// Parse DBus response - look for preference values
if response.contains(" 1 ") {
return Ok("dark".to_string());
} else if response.contains(" 2 ") {
return Ok("light".to_string());
}
}
}
Err("Could not detect portal theme".into())
}
fn detect_system_files_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if we're in a dark terminal (heuristic)
if let Ok(term) = std::env::var("TERM") {
let term_lower = term.to_lowercase();
if term_lower.contains("dark") || term_lower.contains("night") {
return Ok("dark".to_string());
}
}
// Check if we can determine from desktop session
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop_lower = desktop.to_lowercase();
// Some desktops default to dark
if desktop_lower.contains("i3") || desktop_lower.contains("sway") {
// Window managers often use dark themes by default
return Ok("dark".to_string());
}
}
Err("Could not detect theme from system files".into())
}
fn is_command_available(command: &str) -> bool {
Command::new("which")
.arg(command)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// macOS theme detection using osascript
if let Ok(output) = Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to tell appearance preferences to get dark mode",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).to_string();
let result = result.trim();
match result {
"true" => {
return SystemTheme {
theme: "dark".to_string(),
}
}
"false" => {
return SystemTheme {
theme: "light".to_string(),
}
}
_ => {}
}
}
}
// Fallback method using defaults
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
{
if output.status.success() {
let style = String::from_utf8_lossy(&output.stdout).to_string();
let style = style.trim();
if style.to_lowercase() == "dark" {
return SystemTheme {
theme: "dark".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Windows theme detection via registry
// This is a simplified implementation - you might want to use winreg crate for better registry access
if let Ok(output) = Command::new("reg")
.args([
"query",
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
"/v",
"AppsUseLightTheme",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout);
if result.contains("0x0") {
return SystemTheme {
theme: "dark".to_string(),
};
} else if result.contains("0x1") {
return SystemTheme {
theme: "light".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
// Command to expose this functionality to the frontend
#[tauri::command]
pub fn get_system_theme() -> SystemTheme {
let detector = ThemeDetector::new();
detector.detect_system_theme()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_detector_creation() {
let detector = ThemeDetector::new();
let theme = detector.detect_system_theme();
// Should return a valid theme string
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
#[test]
fn test_get_system_theme_command() {
let theme = get_system_theme();
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
}
-16
View File
@@ -448,22 +448,6 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
Ok((last_update, time_until_next))
}
#[tauri::command]
pub async fn check_version_update_needed() -> Result<bool, String> {
Ok(VersionUpdater::should_run_background_update())
}
#[tauri::command]
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
match updater_guard.check_and_run_startup_update().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Failed to run version update check: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
+22 -10
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -10,15 +10,7 @@
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Donut Browser",
"width": 900,
"height": 600,
"resizable": false,
"fullscreen": false
}
],
"windows": [],
"security": {
"csp": null
}
@@ -26,6 +18,7 @@
"bundle": {
"active": true,
"targets": "all",
"category": "Productivity",
"externalBin": ["binaries/nodecar"],
"icon": [
"icons/32x32.png",
@@ -45,6 +38,25 @@
"files": {
"Info.plist": "Info.plist"
}
},
"linux": {
"deb": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"rpm": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"appimage": {
"files": {
"usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
}
}
},
"plugins": {
+2
View File
@@ -4,6 +4,7 @@ import "@/styles/globals.css";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -26,6 +27,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
+100 -125
View File
@@ -2,12 +2,19 @@
import { ChangeVersionDialog } from "@/components/change-version-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
@@ -20,7 +27,8 @@ import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { GoGear, GoPlus } from "react-icons/go";
import { FaDownload } from "react-icons/fa";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
type BrowserTypeString =
| "mullvad-browser"
@@ -43,29 +51,33 @@ export default function Home() {
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
useState<BrowserProfile | null>(null);
const [isClient, setIsClient] = useState(false);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
// Auto-update functionality - only initialize on client
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// App auto-update functionality
const appUpdateNotifications = useAppUpdateNotifications();
const { checkForAppUpdatesManual } = appUpdateNotifications;
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
// Simple profiles loader without updates check (for use as callback)
const loadProfiles = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, []);
const loadProfiles = useCallback(async () => {
if (!isClient) return; // Only run on client side
// Auto-update functionality - pass loadProfiles to refresh profiles after updates
const updateNotifications = useUpdateNotifications(loadProfiles);
const { checkForUpdates, isUpdating } = updateNotifications;
// Profiles loader with update check (for initial load and manual refresh)
const loadProfilesWithUpdateCheck = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
@@ -78,12 +90,12 @@ export default function Home() {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, [checkForUpdates, isClient]);
}, [checkForUpdates]);
useAppUpdateNotifications();
useEffect(() => {
if (!isClient) return; // Only run on client side
void loadProfiles();
void loadProfilesWithUpdateCheck();
// Check for startup default browser prompt
void checkStartupPrompt();
@@ -105,10 +117,11 @@ export default function Home() {
return () => {
clearInterval(updateInterval);
};
}, [loadProfiles, checkForUpdates, isClient]);
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
const checkStartupPrompt = async () => {
if (!isClient) return; // Only run on client side
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
try {
const shouldShow = await invoke<boolean>(
@@ -117,14 +130,14 @@ export default function Home() {
if (shouldShow) {
setSettingsDialogOpen(true);
}
setHasCheckedStartupPrompt(true);
} catch (error) {
console.error("Failed to check startup prompt:", error);
setHasCheckedStartupPrompt(true);
}
};
const checkStartupUrls = async () => {
if (!isClient) return; // Only run on client side
try {
const hasStartupUrl = await invoke<boolean>(
"check_and_handle_startup_url",
@@ -138,8 +151,6 @@ export default function Home() {
};
const listenForUrlEvents = async () => {
if (!isClient) return; // Only run on client side
try {
// Listen for URL open events from the deep link handler (when app is already running)
await listen<string>("url-open-request", (event) => {
@@ -150,10 +161,7 @@ export default function Home() {
// Listen for show profile selector events
await listen<string>("show-profile-selector", (event) => {
console.log("Received show profile selector request:", event.payload);
setPendingUrls((prev) => [
...prev,
{ id: Date.now().toString(), url: event.payload },
]);
setPendingUrls([{ id: Date.now().toString(), url: event.payload }]);
});
// Listen for show create profile dialog events
@@ -173,15 +181,13 @@ export default function Home() {
};
const handleUrlOpen = async (url: string) => {
if (!isClient) return; // Only run on client side
try {
// Use smart profile selection
const result = await invoke<string>("smart_open_url", {
url,
});
console.log("Smart URL opening succeeded:", result);
// URL was handled successfully
// URL was handled successfully, no need to show selector
} catch (error: unknown) {
console.log(
"Smart URL opening failed or requires profile selection:",
@@ -189,7 +195,8 @@ export default function Home() {
);
// Show profile selector for manual selection
setPendingUrls((prev) => [...prev, { id: Date.now().toString(), url }]);
// Replace any existing pending URL with the new one
setPendingUrls([{ id: Date.now().toString(), url }]);
}
};
@@ -270,40 +277,33 @@ export default function Home() {
const runningProfilesRef = useRef<Set<string>>(new Set());
const checkBrowserStatus = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => {
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
}
} catch (err) {
console.error("Failed to check browser status:", err);
}
},
[isClient],
);
} catch (err) {
console.error("Failed to check browser status:", err);
}
}, []);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
setError(null);
// Check if browser is disabled due to ongoing update
@@ -337,11 +337,11 @@ export default function Home() {
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles, checkBrowserStatus, isUpdating, isClient],
[loadProfiles, checkBrowserStatus, isUpdating],
);
useEffect(() => {
if (profiles.length === 0 || !isClient) return;
if (profiles.length === 0) return;
const interval = setInterval(() => {
for (const profile of profiles) {
@@ -352,7 +352,7 @@ export default function Home() {
return () => {
clearInterval(interval);
};
}, [profiles, checkBrowserStatus, isClient]);
}, [profiles, checkBrowserStatus]);
useEffect(() => {
runningProfilesRef.current = runningProfiles;
@@ -408,76 +408,43 @@ export default function Home() {
[loadProfiles],
);
// Don't render anything until we're on the client side to prevent hydration issues
if (!isClient) {
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)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
disabled
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled
className="flex items-center gap-2"
>
<GoPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="p-8 text-center">
<div className="animate-pulse">Loading...</div>
</CardContent>
</Card>
</main>
</div>
);
}
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)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<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">
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-2 items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSettingsDialogOpen(true);
}}
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<GoGear className="mr-2 w-4 h-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setImportProfileDialogOpen(true);
}}
>
<FaDownload className="mr-2 w-4 h-4" />
Import Profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -485,9 +452,9 @@ export default function Home() {
onClick={() => {
setCreateProfileDialogOpen(true);
}}
className="flex items-center gap-2"
className="flex gap-2 items-center"
>
<GoPlus className="h-4 w-4" />
<GoPlus className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
@@ -545,6 +512,14 @@ export default function Home() {
onVersionChanged={() => void loadProfiles()}
/>
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
}}
onImportComplete={() => void loadProfiles()}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
+68 -32
View File
@@ -26,6 +26,8 @@ import {
} 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 { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
@@ -60,9 +62,6 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
@@ -84,13 +83,29 @@ export function CreateProfileDialog({
isVersionDownloaded,
} = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles();
}
}, [isOpen]);
useEffect(() => {
if (supportedBrowsers.length > 0) {
// Set default browser to first supported browser
if (supportedBrowsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (supportedBrowsers.length > 0) {
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
}
}
}, [supportedBrowsers]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
@@ -105,7 +120,7 @@ export function CreateProfileDialog({
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_alpha);
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
@@ -117,22 +132,6 @@ export function CreateProfileDialog({
}
}, [availableVersions, selectedBrowser]);
const loadSupportedBrowsers = async () => {
try {
const browsers = await invoke<BrowserTypeString[]>(
"get_supported_browsers",
);
setSupportedBrowsers(browsers);
if (browsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (browsers.length > 0) {
setSelectedBrowser(browsers[0]);
}
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
};
const loadExistingProfiles = async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
@@ -261,21 +260,58 @@ export function CreateProfileDialog({
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue placeholder="Select browser" />
<SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => (
<SelectItem key={browser} value={browser}>
{browser
.split("-")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ")}
</SelectItem>
))}
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported on this platform)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
+39 -2
View File
@@ -71,7 +71,12 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?: "downloading" | "extracting" | "verifying" | "completed";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
progress?: {
percentage: number;
speed?: string;
@@ -93,13 +98,20 @@ interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps;
| FetchingToastProps
| TwilightUpdateToastProps;
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
@@ -122,6 +134,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
);
case "twilight-update":
return (
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
);
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
@@ -186,6 +202,22 @@ export function UnifiedToast(props: ToastProps) {
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
<p className="text-xs text-gray-600 dark:text-gray-300">
{"hasUpdate" in props && props.hasUpdate
? "New twilight build available for download"
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
{props.browserName} Rolling Release
</p>
)}
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
@@ -206,6 +238,11 @@ export function UnifiedToast(props: ToastProps) {
Verifying installation...
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
Downloading rolling release build...
</p>
)}
</>
)}
</div>
+482
View File
@@ -0,0 +1,482 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
onImportComplete?: () => void;
}
export function ImportProfileDialog({
isOpen,
onClose,
onImportComplete,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importMode, setImportMode] = useState<"auto-detect" | "manual">(
"auto-detect",
);
// Auto-detect state
const [selectedDetectedProfile, setSelectedDetectedProfile] = useState<
string | null
>(null);
const [autoDetectProfileName, setAutoDetectProfileName] = useState("");
// Manual import state
const [manualBrowserType, setManualBrowserType] = useState<string | null>(
null,
);
const [manualProfilePath, setManualProfilePath] = useState("");
const [manualProfileName, setManualProfileName] = useState("");
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
useEffect(() => {
if (isOpen) {
void loadDetectedProfiles();
}
}, [isOpen]);
const loadDetectedProfiles = async () => {
setIsLoading(true);
try {
const profiles = await invoke<DetectedProfile[]>(
"detect_existing_profiles",
);
setDetectedProfiles(profiles);
// Auto-switch to manual mode if no profiles detected
if (profiles.length === 0) {
setImportMode("manual");
} else {
// Auto-select first profile if available
setSelectedDetectedProfile(profiles[0].path);
// Generate default name from the detected profile
const profile = profiles[0];
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
setAutoDetectProfileName(defaultName);
}
} catch (error) {
console.error("Failed to detect existing profiles:", error);
toast.error("Failed to detect existing browser profiles");
} finally {
setIsLoading(false);
}
};
const handleBrowseFolder = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: "Select Browser Profile Folder",
});
if (selected && typeof selected === "string") {
setManualProfilePath(selected);
}
} catch (error) {
console.error("Failed to open folder dialog:", error);
toast.error("Failed to open folder dialog");
}
};
const handleAutoDetectImport = async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: profile.path,
browserType: profile.browser,
newProfileName: autoDetectProfileName.trim(),
});
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
} finally {
setIsImporting(false);
}
};
const handleManualImport = async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: manualProfilePath.trim(),
browserType: manualBrowserType,
newProfileName: manualProfileName.trim(),
});
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
} finally {
setIsImporting(false);
}
};
const handleClose = () => {
setSelectedDetectedProfile(null);
setAutoDetectProfileName("");
setManualBrowserType(null);
setManualProfilePath("");
setManualProfileName("");
// Only reset to auto-detect if there are profiles available
if (detectedProfiles.length > 0) {
setImportMode("auto-detect");
} else {
setImportMode("manual");
}
onClose();
};
// Update auto-detect profile name when selection changes
useEffect(() => {
if (selectedDetectedProfile) {
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (profile) {
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
setAutoDetectProfileName(defaultName);
}
}
}, [selectedDetectedProfile, detectedProfiles]);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Import Browser Profile</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<Button
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</Button>
<Button
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</Button>
</div>
{/* Auto-Detect Mode */}
{importMode === "auto-detect" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Detected Browser Profiles</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in custom
locations.
</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label htmlFor="detected-profile-select" className="mb-2">
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
return (
<SelectItem key={profile.path} value={profile.path}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
<span className="text-xs text-muted-foreground">
{profile.description}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
</p>
</div>
)}
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
)}
</div>
)}
{/* Manual Import Mode */}
{importMode === "manual" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleAutoDetectImport();
}}
disabled={
!selectedDetectedProfile ||
!autoDetectProfileName.trim() ||
isLoading
}
>
Import Detected Profile
</LoadingButton>
) : (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleManualImport();
}}
disabled={
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
}
>
Import Manual Profile
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -401,7 +401,7 @@ export function ProfilesDataTable({
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Rename profile
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -411,7 +411,7 @@ export function ProfilesDataTable({
className="text-red-600"
disabled={!isClient || isRunning || isBrowserUpdating}
>
Delete profile
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+21 -8
View File
@@ -69,16 +69,29 @@ export function ProfileSelectorDialog({
// Auto-select first available profile for link opening
if (profileList.length > 0) {
// Find the first profile that can be used for opening links
const availableProfile = profileList.find((profile) => {
return canUseProfileForLinks(profile, profileList, runningProfiles);
// First, try to find a running profile that can be used for opening links
const runningAvailableProfile = profileList.find((profile) => {
const isRunning = runningProfiles.has(profile.name);
return (
isRunning &&
canUseProfileForLinks(profile, profileList, runningProfiles)
);
});
if (availableProfile) {
setSelectedProfile(availableProfile.name);
if (runningAvailableProfile) {
setSelectedProfile(runningAvailableProfile.name);
} else {
// If no suitable profile found, still select the first one to show UI
setSelectedProfile(profileList[0].name);
// If no running profile is suitable, find the first profile that can be used for opening links
const availableProfile = profileList.find((profile) => {
return canUseProfileForLinks(profile, profileList, runningProfiles);
});
if (availableProfile) {
setSelectedProfile(availableProfile.name);
} else {
// If no suitable profile found, still select the first one to show UI
setSelectedProfile(profileList[0].name);
}
}
}
} catch (error) {
@@ -277,7 +290,7 @@ export function ProfileSelectorDialog({
!canUseForLinks ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent cursor-pointer">
<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">
{(() => {
const IconComponent = getBrowserIcon(
+66 -5
View File
@@ -19,6 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { showSuccessToast } from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
@@ -28,6 +29,7 @@ interface AppSettings {
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
}
interface SettingsDialogProps {
@@ -41,17 +43,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
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);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const { setTheme } = useTheme();
@@ -106,11 +111,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
}
};
const handleClearCache = async () => {
setIsClearingCache(true);
try {
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",
duration: 4000,
});
} catch (error) {
console.error("Failed to clear cache:", error);
} finally {
setIsClearingCache(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
await invoke("save_app_settings", { settings });
// Apply theme change immediately
setTheme(settings.theme);
setOriginalSettings(settings);
onClose();
@@ -130,7 +150,9 @@ 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_updates_enabled !== originalSettings.auto_updates_enabled ||
settings.auto_delete_unused_binaries !==
originalSettings.auto_delete_unused_binaries;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -139,7 +161,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4 overflow-y-auto flex-1 min-h-0">
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Appearance</Label>
@@ -172,7 +194,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
{/* Default Browser Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<Label className="text-base font-medium">Default Browser</Label>
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
{isDefaultBrowser ? "Active" : "Inactive"}
@@ -216,9 +238,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-delete-binaries"
checked={settings.auto_delete_unused_binaries}
onCheckedChange={(checked) => {
updateSetting(
"auto_delete_unused_binaries",
checked as boolean,
);
}}
/>
<Label htmlFor="auto-delete-binaries" className="text-sm">
Automatically delete unused browser binaries
</Label>
</div>
<p className="text-xs text-muted-foreground">
When enabled, Donut Browser will check for browser updates and
notify you when updates are available for your profiles.
notify you when updates are available for your profiles. Unused
binaries will be automatically deleted to save disk space.
</p>
</div>
@@ -244,6 +283,28 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
starts.
</p>
</div>
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
void handleClearCache();
}}
variant="outline"
className="w-full"
>
Clear All Version Cache
</LoadingButton>
<p className="text-xs text-muted-foreground">
Clear all cached browser version data and refresh all browser
versions from their sources. This will force a fresh download of
version information for all browsers.
</p>
</div>
</div>
<DialogFooter className="flex-shrink-0">
+88 -8
View File
@@ -9,6 +9,10 @@ interface AppSettings {
theme: string;
}
interface SystemTheme {
theme: string;
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
@@ -24,9 +28,33 @@ function getSystemTheme(): string {
return "light";
}
// Function to get native system theme (fallback to CSS media query)
async function getNativeSystemTheme(): Promise<string> {
try {
const systemTheme = await invoke<SystemTheme>("get_system_theme");
if (systemTheme.theme === "dark" || systemTheme.theme === "light") {
return systemTheme.theme;
}
// Fallback to CSS media query if native detection returns "unknown"
return getSystemTheme();
} catch (error) {
console.warn(
"Failed to get native system theme, falling back to CSS media query:",
error,
);
// Fallback to CSS media query
return getSystemTheme();
}
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const loadTheme = async () => {
@@ -36,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme();
const systemTheme = await getNativeSystemTheme();
console.log(
"First-time user detected, applying system theme:",
systemTheme,
@@ -64,19 +92,71 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Monitor system theme changes when using "system" theme
useEffect(() => {
if (!mounted || defaultTheme !== "system") {
return;
}
const checkSystemTheme = async () => {
try {
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") {
if (!html.classList.contains("dark")) {
html.classList.add("dark");
html.classList.remove("light");
}
} else {
if (
!html.classList.contains("light") ||
html.classList.contains("dark")
) {
html.classList.add("light");
html.classList.remove("dark");
}
}
} catch (error) {
console.warn("Failed to check system theme:", error);
}
};
// Check system theme every 2 seconds when using system theme
const intervalId = setInterval(() => void checkSystemTheme(), 2000);
// Initial check
void checkSystemTheme();
return () => {
clearInterval(intervalId);
};
}, [mounted, defaultTheme]);
if (isLoading) {
// Detect system theme to show appropriate loading screen
const systemTheme = getSystemTheme();
const loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
const spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
// Use a consistent loading screen that doesn't depend on system theme during SSR
// This prevents hydration mismatch by ensuring server and client render the same initially
let loadingBgColor = "bg-white";
let spinnerColor = "border-gray-900";
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
// Use CSS media query for loading screen since async call would complicate this
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
}
return (
<div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`}
className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
>
<div
className={`animate-spin rounded-full h-8 w-8 border-2 ${spinnerColor} border-t-transparent`}
className={`w-8 h-8 rounded-full border-2 animate-spin ${spinnerColor} border-t-transparent`}
/>
</div>
);
+7
View File
@@ -15,8 +15,15 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
zIndex: 99999,
} as React.CSSProperties
}
toastOptions={{
style: {
zIndex: 99999,
pointerEvents: "auto",
},
}}
{...props}
/>
);
+9 -9
View File
@@ -47,17 +47,17 @@ export function UpdateNotificationComponent({
};
return (
<div className="flex flex-col gap-3 p-4 max-w-md bg-background border border-border rounded-lg shadow-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-3 p-4 max-w-md rounded-lg border shadow-lg bg-background border-border">
<div className="flex gap-2 justify-between items-start">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<span className="font-semibold text-foreground">
{browserDisplayName} Update Available
</span>
<Badge
variant={notification.is_stable_update ? "default" : "secondary"}
>
{notification.is_stable_update ? "Stable" : "Beta"}
{notification.is_stable_update ? "Stable" : "Nightly"}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
@@ -71,20 +71,20 @@ export function UpdateNotificationComponent({
onClick={async () => {
await onDismiss(notification.id);
}}
className="h-6 w-6 p-0 shrink-0"
className="p-0 w-6 h-6 shrink-0"
>
<FaTimes className="h-3 w-3" />
<FaTimes className="w-3 h-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<Button
onClick={handleUpdateClick}
disabled={isUpdating}
size="sm"
className="flex items-center gap-2"
className="flex gap-2 items-center"
>
<FaDownload className="h-3 w-3" />
<FaDownload className="w-3 h-3" />
Update
</Button>
<Button
+6 -6
View File
@@ -30,7 +30,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface VersionSelectorProps {
@@ -75,7 +75,7 @@ export function VersionSelector({
className="justify-between w-full"
>
{selectedVersion ?? placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
@@ -114,11 +114,11 @@ export function VersionSelector({
: "opacity-0",
)}
/>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<span>{version.tag_name}</span>
{version.is_alpha && (
{version.is_nightly && (
<Badge variant="secondary" className="text-xs">
Alpha
Nightly
</Badge>
)}
{isDownloaded && (
@@ -147,7 +147,7 @@ export function VersionSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 h-4 w-4" />
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
export function WindowDragArea() {
const [isMacOS, setIsMacOS] = useState(false);
useEffect(() => {
// Check if we're on macOS using user agent detection
const checkPlatform = () => {
const userAgent = navigator.userAgent.toLowerCase();
setIsMacOS(userAgent.includes("mac"));
};
checkPlatform();
}, []);
const handleMouseDown = (e: React.MouseEvent) => {
// Only handle left mouse button
if (e.button !== 0) return;
// Start dragging asynchronously
const startDrag = async () => {
try {
const window = getCurrentWindow();
await window.startDragging();
} catch (error) {
console.error("Failed to start window dragging:", error);
}
};
void startDrag();
};
// Only render on macOS
if (!isMacOS) {
return null;
}
return (
<div
className="fixed top-0 right-0 left-0 z-50 h-8 cursor-move"
style={{
// Ensure it's above all other content
zIndex: 9999,
// Make it transparent but still capture mouse events
backgroundColor: "transparent",
// Prevent text selection during drag
userSelect: "none",
WebkitUserSelect: "none",
}}
onMouseDown={handleMouseDown}
// Prevent context menu
onContextMenu={(e) => {
e.preventDefault();
}}
/>
);
}
+5 -1
View File
@@ -134,7 +134,11 @@ export function useAppUpdateNotifications() {
{
id: "app-update",
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
position: "top-left",
style: {
zIndex: 99999, // Ensure app updates appear above dialogs
pointerEvents: "auto", // Ensure app updates remain interactive
},
},
);
}, [
+19 -3
View File
@@ -19,7 +19,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface BrowserVersionInfo {
@@ -231,7 +231,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -272,7 +272,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -325,6 +325,22 @@ export function useBrowserDownload() {
setIsDownloading(true);
try {
// Check browser compatibility before attempting download
const isSupported = await invoke<boolean>(
"is_browser_supported_on_platform",
{ browserStr },
);
if (!isSupported) {
const supportedBrowsers = await invoke<string[]>(
"get_supported_browsers",
);
throw new Error(
`${browserName} is not supported on your platform. Supported browsers: ${supportedBrowsers
.map(getBrowserDisplayName)
.join(", ")}`,
);
}
await invoke("download_browser", { browserStr, version });
await loadDownloadedVersions(browserStr);
} catch (error) {
+59
View File
@@ -0,0 +1,59 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
export interface BrowserSupportInfo {
supportedBrowsers: string[];
isLoading: boolean;
error: string | null;
}
export function useBrowserSupport() {
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadSupportedBrowsers = async () => {
try {
setIsLoading(true);
setError(null);
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (err) {
console.error("Failed to load supported browsers:", err);
setError(
err instanceof Error
? err.message
: "Failed to load supported browsers",
);
} finally {
setIsLoading(false);
}
};
void loadSupportedBrowsers();
}, []);
const isBrowserSupported = (browser: string): boolean => {
return supportedBrowsers.includes(browser);
};
const checkBrowserSupport = async (browser: string): Promise<boolean> => {
try {
return await invoke<boolean>("is_browser_supported_on_platform", {
browserStr: browser,
});
} catch (err) {
console.error(`Failed to check support for browser ${browser}:`, err);
return false;
}
};
return {
supportedBrowsers,
isLoading,
error,
isBrowserSupported,
checkBrowserSupport,
};
}
+58 -20
View File
@@ -13,35 +13,66 @@ interface UpdateNotification {
affected_profiles: string[];
is_stable_update: boolean;
timestamp: number;
is_rolling_release: boolean;
}
export function useUpdateNotifications() {
export function useUpdateNotifications(
onProfilesUpdated?: () => Promise<void>,
) {
const [notifications, setNotifications] = useState<UpdateNotification[]>([]);
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
new Set(),
);
const [isClient, setIsClient] = useState(false);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const [dismissedNotifications, setDismissedNotifications] = useState<
Set<string>
>(new Set());
const checkForUpdates = useCallback(async () => {
if (!isClient) return; // Only run on client side
try {
const updates = await invoke<UpdateNotification[]>(
"check_for_browser_updates",
);
setNotifications(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;
});
setNotifications(filteredUpdates);
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
// to avoid circular dependencies
} catch (error) {
console.error("Failed to check for updates:", error);
}
}, [isClient]);
}, [dismissedNotifications]);
const handleUpdate = useCallback(
async (browser: string, newVersion: string) => {
@@ -117,6 +148,11 @@ export function useUpdateNotifications() {
duration: 5000,
});
}
// Trigger profile refresh to update UI with new versions
if (onProfilesUpdated) {
void onProfilesUpdated();
}
} catch (downloadError) {
console.error("Failed to download browser:", downloadError);
@@ -158,28 +194,28 @@ export function useUpdateNotifications() {
});
}
},
[notifications, checkForUpdates],
[notifications, checkForUpdates, onProfilesUpdated],
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!isClient) return; // Only run on client side
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, isClient],
[checkForUpdates],
);
// Separate effect to show toasts when notifications change
useEffect(() => {
if (!isClient) return;
for (const notification of notifications) {
const isUpdating = updatingBrowsers.has(notification.browser);
@@ -196,12 +232,14 @@ export function useUpdateNotifications() {
id: notification.id,
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
// Remove transparent styling to fix background issue
style: undefined,
style: {
zIndex: 99999, // Ensure notifications appear above dialogs
pointerEvents: "auto", // Ensure notifications remain interactive
},
},
);
}
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]);
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
return {
notifications,
+51 -4
View File
@@ -24,7 +24,12 @@ export interface ErrorToastProps extends BaseToastProps {
export interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?: "downloading" | "extracting" | "verifying" | "completed";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
progress?: {
percentage: number;
speed?: string;
@@ -46,13 +51,20 @@ export interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
export interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
export type ToastProps =
| LoadingToastProps
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps;
| FetchingToastProps
| TwilightUpdateToastProps;
// Unified toast function
export function showToast(props: ToastProps & { id?: string }) {
@@ -81,6 +93,9 @@ export function showToast(props: ToastProps & { id?: string }) {
case "version-update":
duration = 15000;
break;
case "twilight-update":
duration = 10000;
break;
case "success":
duration = 3000;
break;
@@ -101,6 +116,8 @@ export function showToast(props: ToastProps & { id?: string }) {
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
pointerEvents: "auto",
},
});
} else if (props.type === "error") {
@@ -112,6 +129,8 @@ export function showToast(props: ToastProps & { id?: string }) {
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
pointerEvents: "auto",
},
});
} else {
@@ -123,6 +142,8 @@ export function showToast(props: ToastProps & { id?: string }) {
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
pointerEvents: "auto",
},
});
}
@@ -149,7 +170,12 @@ export function showLoadingToast(
export function showDownloadToast(
browserName: string,
version: string,
stage: "downloading" | "extracting" | "verifying" | "completed",
stage:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)",
progress?: { percentage: number; speed?: string; eta?: string },
options?: { suppressCompletionToast?: boolean },
) {
@@ -160,7 +186,9 @@ export function showDownloadToast(
? `Downloading ${browserName} ${version}`
: stage === "extracting"
? `Extracting ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
: stage === "downloading (twilight rolling release)"
? `Downloading ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
// Don't show completion toast if suppressed (for auto-update scenarios)
if (stage === "completed" && options?.suppressCompletionToast) {
@@ -245,6 +273,25 @@ export function showErrorToast(
});
}
export function showTwilightUpdateToast(
browserName: string,
options?: {
id?: string;
description?: string;
hasUpdate?: boolean;
duration?: number;
},
) {
return showToast({
type: "twilight-update",
title: options?.hasUpdate
? `${browserName} twilight update available`
: `Checking for ${browserName} twilight updates...`,
browserName,
...options,
});
}
// Generic helper for dismissing toasts
export function dismissToast(id: string) {
sonnerToast.dismiss(id);
+20
View File
@@ -123,3 +123,23 @@
@apply bg-background text-foreground;
}
}
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
.toaster,
[data-sonner-toaster] {
z-index: 99999 !important;
pointer-events: auto !important;
}
[data-sonner-toast] {
z-index: 99999 !important;
pointer-events: auto !important;
}
/* Ensure toast buttons and interactive elements work */
[data-sonner-toast] button,
[data-sonner-toast] [role="button"],
[data-sonner-toast] input,
[data-sonner-toast] select {
pointer-events: auto !important;
}
+7
View File
@@ -20,6 +20,13 @@ export interface BrowserProfile {
last_launch?: number;
}
export interface DetectedProfile {
browser: string;
name: string;
path: string;
description: string;
}
export interface AppUpdateInfo {
current_version: string;
new_version: string;
+13
View File
@@ -0,0 +1,13 @@
export default {
darkMode: "class",
theme: {
extend: {
colors: {
black: "#000000",
},
backgroundColor: {
dark: "#000000",
},
},
},
};