mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-04 01:25:12 +02:00
Compare commits
42 Commits
alpha-210924e
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a5b807828 | |||
| d0a5c16ce9 | |||
| e2e1ad1582 | |||
| cb61861503 | |||
| 1950ef0098 | |||
| 814875c28e | |||
| b06ca4f11e | |||
| 3ab1ea61e8 | |||
| a0599ecfc1 | |||
| 6c834b3003 | |||
| 269b4dbe77 | |||
| ef00854307 | |||
| 03d915e5c7 | |||
| 91b12e80e5 | |||
| 3af581c4ab | |||
| 7a85edfb8a | |||
| 141a5f06a4 | |||
| 7a3857c06a | |||
| ed26786fdb | |||
| 966268ff05 | |||
| 87ae696d7a | |||
| 7e92b290b6 | |||
| eb62e0abf9 | |||
| 0ed5adf2ba | |||
| dd91aaeea0 | |||
| 6a3407796d | |||
| eaa1a823db | |||
| 63900bd0ad | |||
| 31326c2d1f | |||
| 006a146770 | |||
| e3275248f7 | |||
| 5c23c77896 | |||
| 03a3e9fc56 | |||
| 26a5be55f1 | |||
| a58a814369 | |||
| adcd78c275 | |||
| 1ed07f09c5 | |||
| 8f7393ea66 | |||
| 8980056e44 | |||
| 885c6a1446 | |||
| a67803dbc8 | |||
| e3e6fd98c8 |
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: "Bug report"
|
||||
about: Report a bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Hi there! To expedite issue processing please search open and closed issues before submitting a new one. Existing issues often contain information about workarounds, resolution, or progress updates.
|
||||
-->
|
||||
|
||||
# Bug Report
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem. -->
|
||||
|
||||
## Is this a regression?
|
||||
|
||||
<!-- Did this behavior use to work in the previous version? -->
|
||||
|
||||
## Minimal Reproduction
|
||||
|
||||
<!-- Clear steps to re-produce the issue. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
|
||||
|
||||
## Exception or Error or Screenshot
|
||||
|
||||
<!-- Please provide any error messages, stack traces, or screenshots that might help -->
|
||||
|
||||
<pre><code>
|
||||
<!-- Paste error logs here -->
|
||||
</code></pre>
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Feature request"
|
||||
about: Suggest a feature
|
||||
---
|
||||
|
||||
# Feature Request
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem or missing capability. -->
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
<!-- If you have a solution in mind, please describe it. -->
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
<!-- Have you considered any alternative solutions or workarounds? -->
|
||||
|
||||
## Use Case
|
||||
|
||||
<!-- Describe the specific use case and how this feature would benefit users. -->
|
||||
|
||||
## Priority
|
||||
|
||||
<!-- How important is this feature to you? -->
|
||||
|
||||
- [ ] Low - Nice to have
|
||||
- [ ] Medium - Would improve my workflow
|
||||
- [ ] High - Critical for my use case
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context, mockups, or examples about the feature request here. -->
|
||||
@@ -0,0 +1,54 @@
|
||||
# ✨ Pull Request
|
||||
|
||||
### 📓 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
|
||||
|
||||
<!-- 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
|
||||
|
||||
<!-- Mark the relevant option with an "x". -->
|
||||
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🧹 Code cleanup/refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
|
||||
### 🖼️ 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
|
||||
|
||||
<!-- Mark completed items with an "x". -->
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
### 🧪 How Has This Been Tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. -->
|
||||
|
||||
### 📱 Platform Testing
|
||||
|
||||
<!-- Which platforms have you tested on? -->
|
||||
|
||||
- [ ] macOS (Intel)
|
||||
- [ ] macOS (Apple Silicon)
|
||||
- [ ] Windows (if applicable)
|
||||
- [ ] Linux (if applicable)
|
||||
|
||||
### 📋 Additional Notes
|
||||
|
||||
<!-- Any additional information that reviewers should know about this PR. -->
|
||||
@@ -19,16 +19,3 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
name: Lint Node.js
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "src-tauri/**"
|
||||
@@ -16,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -46,4 +49,4 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run lint step
|
||||
run: pnpm lint
|
||||
run: pnpm run lint:js
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
name: Lint Rust
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "src/**"
|
||||
@@ -20,7 +23,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
|
||||
@@ -8,30 +8,21 @@ on:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
STABLE_RELEASE: "true"
|
||||
|
||||
jobs:
|
||||
# Wait for linting jobs to complete first
|
||||
wait-for-linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for Rust linting to complete
|
||||
uses: lewagon/wait-on-check-action@v1.3.4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
check-name: "Lint Rust"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
wait-interval: 10
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
|
||||
- name: Wait for JavaScript linting to complete
|
||||
uses: lewagon/wait-on-check-action@v1.3.4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
check-name: "Lint Node.js"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
wait-interval: 10
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
needs: wait-for-linting
|
||||
needs: [lint-js, lint-rust]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -135,10 +126,11 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
with:
|
||||
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 }}
|
||||
|
||||
@@ -10,7 +10,18 @@ env:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
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
|
||||
|
||||
rolling-release:
|
||||
needs: [lint-js, lint-rust]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -83,10 +94,13 @@ 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:
|
||||
tagName: "alpha-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Alpha (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Alpha Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
tagName: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# nodecar
|
||||
nodecar/dist
|
||||
nodecar/node_modules
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Code of Conduct
|
||||
|
||||
All participants of the Donut Browser project (referred to as "the project") are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
|
||||
|
||||
## The Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## The Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- Trolling, insulting/derogatory comments, public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Not being respectful to reasonable communication boundaries, such as 'leave me alone,' 'go away,' or 'I'm not discussing this with you.'
|
||||
- Other conduct which you know could reasonably be considered inappropriate in a professional setting.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Violations of the Code of Conduct may be reported by pinging @zhom on Github. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
# Contributing to Donut Browser
|
||||
|
||||
Contributions are welcome and always appreciated! 🍩
|
||||
|
||||
To begin working on an issue, simply leave a comment indicating that you're taking it on. There's no need to be officially assigned to the issue before you start.
|
||||
|
||||
## Before Starting
|
||||
|
||||
Do keep in mind before you start working on an issue / posting a PR:
|
||||
|
||||
- Search existing PRs related to that issue which might close them
|
||||
- Confirm if other contributors are working on the same issue
|
||||
- Check if the feature aligns with our roadmap and project goals
|
||||
|
||||
## Tips & Things to Consider
|
||||
|
||||
- PRs with tests are highly appreciated
|
||||
- Avoid adding third party libraries, whenever possible
|
||||
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
|
||||
- If you are unsure where to start, open a discussion and we will point you to a good first issue
|
||||
|
||||
## Development Setup
|
||||
|
||||
Ensure you have the following dependencies installed:
|
||||
|
||||
- Node.js (see `.node-version` for exact version)
|
||||
- pnpm package manager
|
||||
- Latest Rust and Cargo toolchain
|
||||
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## Run Locally
|
||||
|
||||
After having the above dependencies installed, proceed through the following steps to setup the codebase locally:
|
||||
|
||||
1. **Fork the project** & [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it locally.
|
||||
|
||||
2. **Create a new separate branch.**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-feature-name
|
||||
```
|
||||
|
||||
3. **Install frontend dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Install nodecar dependencies**
|
||||
|
||||
```bash
|
||||
cd nodecar
|
||||
pnpm install --ignore-workspace --frozen-lockfile
|
||||
cd ..
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
This will start the app for local development with live reloading.
|
||||
|
||||
## Code Style & Quality
|
||||
|
||||
We use several tools to maintain code quality:
|
||||
|
||||
- **Biome** for JavaScript/TypeScript linting and formatting
|
||||
- **Clippy** for Rust linting
|
||||
- **rustfmt** for Rust formatting
|
||||
|
||||
### Before Committing
|
||||
|
||||
Run these commands to ensure your code meets our standards:
|
||||
|
||||
```bash
|
||||
# Format and lint frontend code
|
||||
pnpm format:js
|
||||
|
||||
# Format and lint Rust code
|
||||
pnpm format:rust
|
||||
|
||||
# Run all linting
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
It is crucial to test your code before submitting a pull request. Please ensure that you can make a complete production build before you submit your code for merging.
|
||||
|
||||
```bash
|
||||
# Build the frontend
|
||||
pnpm build
|
||||
|
||||
# Build the backend
|
||||
cd src-tauri && cargo build
|
||||
|
||||
# Build the Tauri application
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
Make sure the build completes successfully without errors.
|
||||
|
||||
## Testing
|
||||
|
||||
- Always test your changes on the target platform
|
||||
- Test both development and production builds
|
||||
- Verify that existing functionality still works
|
||||
- Add tests for new features when possible
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
🎉 Now that you're ready to submit your code for merging, there are some points to keep in mind:
|
||||
|
||||
### PR Description
|
||||
|
||||
- Fill your PR description template accordingly
|
||||
- Have an appropriate title and description
|
||||
- Include relevant screenshots for UI changes. If you can include video/gifs, it is even better.
|
||||
- Reference related issues
|
||||
|
||||
### Linking Issues
|
||||
|
||||
If your PR fixes an issue, add this line **in the body** of the Pull Request description:
|
||||
|
||||
```text
|
||||
Fixes #00000
|
||||
```
|
||||
|
||||
If your PR is referencing an issue:
|
||||
|
||||
```text
|
||||
Refs #00000
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Code follows our style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
### Options
|
||||
|
||||
- Ensure that "Allow edits from maintainers" option is checked
|
||||
|
||||
## Types of Contributions
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When filing bug reports, please include:
|
||||
|
||||
- Clear description of the issue
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Environment details (OS, version, etc.)
|
||||
- Screenshots or error logs if applicable
|
||||
|
||||
### Feature Requests
|
||||
|
||||
When suggesting new features:
|
||||
|
||||
- Explain the use case and why it's valuable
|
||||
- Describe the desired behavior
|
||||
- Consider alternatives you've thought of
|
||||
- Check if it aligns with our roadmap
|
||||
|
||||
### Code Contributions
|
||||
|
||||
- Bug fixes
|
||||
- New features
|
||||
- Performance improvements
|
||||
- Documentation updates
|
||||
- Test coverage improvements
|
||||
|
||||
### Documentation
|
||||
|
||||
- README improvements
|
||||
- Code comments
|
||||
- API documentation
|
||||
- Tutorial content
|
||||
- Translation work
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Donut Browser is built with:
|
||||
|
||||
- **Frontend**: Next.js React application
|
||||
- **Backend**: Tauri (Rust) for native functionality
|
||||
- **Node.js Sidecar**: `nodecar` binary for proxy support
|
||||
- **Build System**: GitHub Actions for CI/CD
|
||||
|
||||
Understanding this architecture will help you contribute more effectively.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Issues**: Use for bug reports and feature requests
|
||||
- **Discussions**: Use for questions and general discussion
|
||||
- **Pull Requests**: Use for code contributions
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
|
||||
|
||||
## Recognition
|
||||
|
||||
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Donut Browser! 🍩✨
|
||||
@@ -1,3 +1,63 @@
|
||||
# Donut Browser
|
||||
<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>
|
||||
|
||||
TODO
|
||||
<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
|
||||
|
||||
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ **macOS** (Intel & Apple Silicon)
|
||||
- 🔄 **Windows** (Planned)
|
||||
- 🔄 **Linux** (Planned)
|
||||
|
||||
## Development
|
||||
|
||||
### Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Issues
|
||||
|
||||
If you face any problems while using the application, please [open an issue](https://github.com/zhom/donutbrowser/issues).
|
||||
|
||||
## Community
|
||||
|
||||
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)
|
||||
|
||||
## 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "AGPL-3.0",
|
||||
"packageManager": "pnpm@10.6.1",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
|
||||
+10
-8
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check src/ && tsc --noEmit && next lint",
|
||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
|
||||
"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:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"format:js": "biome check src/ --fix",
|
||||
"format:rust": "cd src-tauri && cargo fmt --all",
|
||||
"format:biome": "biome check src/ --fix",
|
||||
"format": "pnpm format:js && pnpm format:rust"
|
||||
"format": "pnpm format:js && pnpm format:rust",
|
||||
"cargo": "cd src-tauri && cargo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
@@ -35,7 +37,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",
|
||||
@@ -60,7 +62,7 @@
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.3.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
|
||||
Generated
+928
-942
File diff suppressed because it is too large
Load Diff
Generated
+554
-614
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
version = "0.2.4"
|
||||
description = "Browser Orchestrator"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -20,7 +20,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri = { version = "2", features = ["devtools", "test"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -33,12 +33,15 @@ lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
zip = "4"
|
||||
async-trait = "0.1"
|
||||
core-foundation="0.10"
|
||||
futures-util = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation="0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
tokio-test = "0.4.4"
|
||||
wiremock = "0.6"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.2.4</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -5,5 +5,26 @@ fn main() {
|
||||
println!("cargo:rustc-link-lib=framework=CoreServices");
|
||||
}
|
||||
|
||||
// Inject build version based on environment variables set by CI
|
||||
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
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
|
||||
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
|
||||
// For nightly builds, use commit hash
|
||||
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
|
||||
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
|
||||
} else {
|
||||
// Development build fallback
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
+722
-278
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
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,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppRelease {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub prerelease: bool,
|
||||
pub assets: Vec<AppReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppUpdateInfo {
|
||||
pub current_version: String,
|
||||
pub new_version: String,
|
||||
pub release_notes: String,
|
||||
pub download_url: String,
|
||||
pub is_nightly: bool,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AppAutoUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
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
|
||||
pub fn get_current_version() -> String {
|
||||
// Use build-time injected version instead of CARGO_PKG_VERSION
|
||||
env!("BUILD_VERSION").to_string()
|
||||
}
|
||||
|
||||
/// Check for app updates
|
||||
pub async fn check_for_updates(
|
||||
&self,
|
||||
) -> Result<Option<AppUpdateInfo>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let current_version = Self::get_current_version();
|
||||
let is_nightly = Self::is_nightly_build();
|
||||
|
||||
println!("=== App Update Check ===");
|
||||
println!("Current version: {current_version}");
|
||||
println!("Is nightly build: {is_nightly}");
|
||||
println!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));
|
||||
|
||||
let releases = self.fetch_app_releases().await?;
|
||||
println!("Fetched {} releases from GitHub", releases.len());
|
||||
|
||||
// Filter releases based on build type
|
||||
let filtered_releases: Vec<&AppRelease> = if is_nightly {
|
||||
// For nightly builds, look for nightly releases
|
||||
let nightly_releases: Vec<&AppRelease> = releases
|
||||
.iter()
|
||||
.filter(|release| release.tag_name.starts_with("nightly-"))
|
||||
.collect();
|
||||
println!("Found {} nightly releases", nightly_releases.len());
|
||||
nightly_releases
|
||||
} else {
|
||||
// For stable builds, look for stable releases (semver format)
|
||||
let stable_releases: Vec<&AppRelease> = releases
|
||||
.iter()
|
||||
.filter(|release| {
|
||||
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
|
||||
})
|
||||
.collect();
|
||||
println!("Found {} stable releases", stable_releases.len());
|
||||
stable_releases
|
||||
};
|
||||
|
||||
if filtered_releases.is_empty() {
|
||||
println!("No releases found for build type (nightly: {is_nightly})");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get the latest release
|
||||
let latest_release = filtered_releases[0];
|
||||
println!(
|
||||
"Latest release: {} ({})",
|
||||
latest_release.tag_name, latest_release.name
|
||||
);
|
||||
|
||||
// Check if we need to update
|
||||
if self.should_update(¤t_version, &latest_release.tag_name, is_nightly) {
|
||||
println!("Update available!");
|
||||
|
||||
// Find the appropriate asset for current platform
|
||||
if let Some(download_url) = self.get_download_url_for_platform(&latest_release.assets) {
|
||||
let update_info = AppUpdateInfo {
|
||||
current_version,
|
||||
new_version: latest_release.tag_name.clone(),
|
||||
release_notes: latest_release.body.clone(),
|
||||
download_url,
|
||||
is_nightly,
|
||||
published_at: latest_release.published_at.clone(),
|
||||
};
|
||||
|
||||
println!(
|
||||
"Update info prepared: {} -> {}",
|
||||
update_info.current_version, update_info.new_version
|
||||
);
|
||||
return Ok(Some(update_info));
|
||||
} else {
|
||||
println!("No suitable download asset found for current platform");
|
||||
}
|
||||
} else {
|
||||
println!("No update needed");
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Fetch app releases from GitHub
|
||||
async fn fetch_app_releases(
|
||||
&self,
|
||||
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "donutbrowser")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API request failed: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let releases: Vec<AppRelease> = response.json().await?;
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
|
||||
if is_nightly {
|
||||
// For nightly builds, always update if there's a newer nightly
|
||||
if let (Some(current_hash), Some(new_hash)) = (
|
||||
current_version.strip_prefix("nightly-"),
|
||||
new_version.strip_prefix("nightly-"),
|
||||
) {
|
||||
// Different commit hashes mean we should update
|
||||
let should_update = new_hash != current_hash;
|
||||
println!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
|
||||
return should_update;
|
||||
}
|
||||
|
||||
// If current version doesn't have nightly prefix but we're in nightly mode,
|
||||
// this could be a dev build or stable build upgrading to nightly
|
||||
if !current_version.starts_with("nightly-") {
|
||||
println!("Upgrading from non-nightly to nightly: {new_version}");
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// For stable builds, use semantic versioning comparison
|
||||
let should_update = self.is_version_newer(new_version, current_version);
|
||||
println!("Stable comparison: {new_version} > {current_version} = {should_update}");
|
||||
return should_update;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare semantic versions (returns true if version1 > version2)
|
||||
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
|
||||
let v1 = self.parse_semver(version1);
|
||||
let v2 = self.parse_semver(version2);
|
||||
v1 > v2
|
||||
}
|
||||
|
||||
/// Parse semantic version string into comparable tuple
|
||||
fn parse_semver(&self, version: &str) -> (u32, u32, u32) {
|
||||
let clean_version = version.trim_start_matches('v');
|
||||
let parts: Vec<&str> = clean_version.split('.').collect();
|
||||
|
||||
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
(major, minor, patch)
|
||||
}
|
||||
|
||||
/// Get the appropriate download URL for the current platform
|
||||
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
|
||||
let arch = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") {
|
||||
"x64"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
println!("Looking for assets with architecture: {arch}");
|
||||
for asset in assets {
|
||||
println!("Found asset: {}", asset.name);
|
||||
}
|
||||
|
||||
// Priority 1: Look for exact architecture match in DMG
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains(&format!("_{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("-{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("_{arch}_"))
|
||||
|| asset.name.contains(&format!("-{arch}-")))
|
||||
{
|
||||
println!("Found exact architecture match: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Look for x86_64 variations if we're looking for x64
|
||||
if arch == "x64" {
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
|
||||
{
|
||||
println!("Found x86_64 variant: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Look for arm64 variations if we're looking for aarch64
|
||||
if arch == "aarch64" {
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
|
||||
{
|
||||
println!("Found arm64 variant: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Fallback to any macOS DMG
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.to_lowercase().contains("macos")
|
||||
|| asset.name.to_lowercase().contains("darwin")
|
||||
|| !asset.name.contains(".app.tar.gz"))
|
||||
{
|
||||
// Exclude app.tar.gz files
|
||||
println!("Found fallback DMG: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
println!("No suitable asset found for platform");
|
||||
None
|
||||
}
|
||||
|
||||
/// Download and install app update
|
||||
pub async fn download_and_install_update(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
update_info: &AppUpdateInfo,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create temporary directory for download
|
||||
let temp_dir = std::env::temp_dir().join("donut_app_update");
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
// Extract filename from URL
|
||||
let filename = update_info
|
||||
.download_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("update.dmg")
|
||||
.to_string();
|
||||
|
||||
// Emit download start event
|
||||
let _ = app_handle.emit("app-update-progress", "Downloading update...");
|
||||
|
||||
// Download the update
|
||||
let download_path = self
|
||||
.download_update(&update_info.download_url, &temp_dir, &filename)
|
||||
.await?;
|
||||
|
||||
// Emit extraction start event
|
||||
let _ = app_handle.emit("app-update-progress", "Preparing update...");
|
||||
|
||||
// Extract the update
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
// Emit installation start event
|
||||
let _ = app_handle.emit("app-update-progress", "Installing update...");
|
||||
|
||||
// Install the update (overwrite current app)
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
|
||||
// Clean up temporary files
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// Emit completion event
|
||||
let _ = app_handle.emit("app-update-progress", "Update completed. Restarting...");
|
||||
|
||||
// Restart the application
|
||||
self.restart_application().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file
|
||||
async fn download_update(
|
||||
&self,
|
||||
download_url: &str,
|
||||
dest_dir: &Path,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = dest_dir.join(filename);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(download_url)
|
||||
.header("User-Agent", "donutbrowser")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let mut file = fs::File::create(&file_path)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
file.write_all(&chunk)?;
|
||||
}
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Extract the update using the extraction module
|
||||
async fn extract_update(
|
||||
&self,
|
||||
archive_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let extractor = Extractor::new();
|
||||
|
||||
let extension = archive_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match extension {
|
||||
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
|
||||
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the update by replacing the current app
|
||||
async fn install_update(
|
||||
&self,
|
||||
new_app_path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current application bundle path
|
||||
let current_app_path = self.get_current_app_path()?;
|
||||
|
||||
// Create a backup of the current app
|
||||
let backup_path = current_app_path.with_extension("app.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_dir_all(&backup_path)?;
|
||||
}
|
||||
|
||||
// Move current app to backup
|
||||
fs::rename(¤t_app_path, &backup_path)?;
|
||||
|
||||
// Move new app to current location
|
||||
fs::rename(new_app_path, ¤t_app_path)?;
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current application bundle path
|
||||
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()?;
|
||||
|
||||
// Navigate up to find the .app bundle
|
||||
let mut current = exe_path.as_path();
|
||||
while let Some(parent) = current.parent() {
|
||||
if parent.extension().is_some_and(|ext| ext == "app") {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
}
|
||||
|
||||
/// Restart the application
|
||||
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Create a temporary restart script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.sh");
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Wait a bit more to ensure clean exit
|
||||
sleep 1
|
||||
|
||||
# Start the new application
|
||||
open "{}"
|
||||
|
||||
# Clean up this script
|
||||
rm "{}"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Make the script executable
|
||||
let _ = Command::new("chmod")
|
||||
.args(["+x", script_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for app updates: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_app_update(
|
||||
app_handle: tauri::AppHandle,
|
||||
update_info: AppUpdateInfo,
|
||||
) -> Result<(), String> {
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.download_and_install_update(&app_handle, &update_info)
|
||||
.await
|
||||
.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");
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for app updates: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_nightly_build() {
|
||||
// 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]
|
||||
fn test_version_comparison() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Test semantic version comparison
|
||||
assert!(updater.is_version_newer("v1.1.0", "v1.0.0"));
|
||||
assert!(updater.is_version_newer("v2.0.0", "v1.9.9"));
|
||||
assert!(updater.is_version_newer("v1.0.1", "v1.0.0"));
|
||||
assert!(!updater.is_version_newer("v1.0.0", "v1.0.0"));
|
||||
assert!(!updater.is_version_newer("v1.0.0", "v1.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
assert_eq!(updater.parse_semver("v1.2.3"), (1, 2, 3));
|
||||
assert_eq!(updater.parse_semver("1.2.3"), (1, 2, 3));
|
||||
assert_eq!(updater.parse_semver("v2.0.0"), (2, 0, 0));
|
||||
assert_eq!(updater.parse_semver("0.1.0"), (0, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_update_stable() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Stable version updates
|
||||
assert!(updater.should_update("v1.0.0", "v1.1.0", false));
|
||||
assert!(updater.should_update("v1.0.0", "v2.0.0", false));
|
||||
assert!(!updater.should_update("v1.1.0", "v1.0.0", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_update_nightly() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Nightly version updates
|
||||
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
|
||||
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
|
||||
|
||||
// Upgrade from stable to nightly
|
||||
assert!(updater.should_update("v1.0.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]
|
||||
fn test_should_update_edge_cases() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Test with different nightly formats
|
||||
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
|
||||
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
|
||||
|
||||
// Test stable version edge cases
|
||||
assert!(updater.should_update("v0.9.9", "v1.0.0", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v0.9.9", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
|
||||
|
||||
// Test version without 'v' prefix
|
||||
assert!(updater.should_update("0.9.9", "v1.0.0", false));
|
||||
assert!(updater.should_update("v0.9.9", "1.0.0", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_download_url_for_platform() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
let 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 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]
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ impl AutoUpdater {
|
||||
let settings = self
|
||||
.settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
if !settings.auto_updates_enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -57,7 +57,7 @@ impl AutoUpdater {
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {}", e))?;
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let mut notifications = Vec::new();
|
||||
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
|
||||
|
||||
@@ -188,7 +188,7 @@ impl AutoUpdater {
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{}-{}", browser, version);
|
||||
let download_key = format!("{browser}-{version}");
|
||||
state.auto_update_downloads.insert(download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
Ok(())
|
||||
@@ -201,7 +201,7 @@ impl AutoUpdater {
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{}-{}", browser, version);
|
||||
let download_key = format!("{browser}-{version}");
|
||||
state.auto_update_downloads.remove(&download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
Ok(())
|
||||
@@ -214,7 +214,7 @@ impl AutoUpdater {
|
||||
version: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{}-{}", browser, version);
|
||||
let download_key = format!("{browser}-{version}");
|
||||
Ok(state.auto_update_downloads.contains(&download_key))
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ impl AutoUpdater {
|
||||
state.disabled_browsers.insert(browser.to_string());
|
||||
|
||||
// Mark this download as auto-update for toast suppression
|
||||
let download_key = format!("{}-{}", browser, new_version);
|
||||
let download_key = format!("{browser}-{new_version}");
|
||||
state.auto_update_downloads.insert(download_key);
|
||||
|
||||
self.save_auto_update_state(&state)?;
|
||||
@@ -261,7 +261,7 @@ impl AutoUpdater {
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {}", e))?;
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let mut updated_profiles = Vec::new();
|
||||
|
||||
@@ -308,7 +308,7 @@ impl AutoUpdater {
|
||||
// Remove browser from disabled list and clean up auto-update tracking
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
state.disabled_browsers.remove(browser);
|
||||
let download_key = format!("{}-{}", browser, new_version);
|
||||
let download_key = format!("{browser}-{new_version}");
|
||||
state.auto_update_downloads.remove(&download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
|
||||
@@ -409,7 +409,7 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
|
||||
let notifications = updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for updates: {}", e))?;
|
||||
.map_err(|e| format!("Failed to check for updates: {e}"))?;
|
||||
let grouped = updater.group_update_notifications(notifications);
|
||||
Ok(grouped)
|
||||
}
|
||||
@@ -420,7 +420,7 @@ pub async fn start_browser_update(browser: String, new_version: String) -> Resul
|
||||
updater
|
||||
.start_browser_update(&browser, &new_version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start browser update: {}", e))
|
||||
.map_err(|e| format!("Failed to start browser update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -429,7 +429,7 @@ pub async fn complete_browser_update(browser: String) -> Result<(), String> {
|
||||
updater
|
||||
.complete_browser_update(&browser)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to complete browser update: {}", e))
|
||||
.map_err(|e| format!("Failed to complete browser update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -437,7 +437,7 @@ pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, Str
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.is_browser_disabled(&browser)
|
||||
.map_err(|e| format!("Failed to check browser status: {}", e))
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -445,7 +445,7 @@ pub async fn dismiss_update_notification(notification_id: String) -> Result<(),
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.dismiss_update_notification(¬ification_id)
|
||||
.map_err(|e| format!("Failed to dismiss notification: {}", e))
|
||||
.map_err(|e| format!("Failed to dismiss notification: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -457,7 +457,7 @@ pub async fn complete_browser_update_with_auto_update(
|
||||
updater
|
||||
.complete_browser_update_with_auto_update(&browser, &new_version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to complete browser update: {}", e))
|
||||
.map_err(|e| format!("Failed to complete browser update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -465,7 +465,7 @@ pub async fn mark_auto_update_download(browser: String, version: String) -> Resu
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.mark_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to mark auto-update download: {}", e))
|
||||
.map_err(|e| format!("Failed to mark auto-update download: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -473,7 +473,7 @@ pub async fn remove_auto_update_download(browser: String, version: String) -> Re
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.remove_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to remove auto-update download: {}", e))
|
||||
.map_err(|e| format!("Failed to remove auto-update download: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -481,7 +481,7 @@ pub async fn is_auto_update_download(browser: String, version: String) -> Result
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.is_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to check auto-update download: {}", e))
|
||||
.map_err(|e| format!("Failed to check auto-update download: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -493,7 +493,7 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
profile_path: format!("/tmp/{}", name),
|
||||
profile_path: format!("/tmp/{name}"),
|
||||
process_id: None,
|
||||
proxy: None,
|
||||
last_launch: None,
|
||||
|
||||
@@ -44,7 +44,7 @@ impl BrowserType {
|
||||
"brave" => Ok(BrowserType::Brave),
|
||||
"zen" => Ok(BrowserType::Zen),
|
||||
"tor-browser" => Ok(BrowserType::TorBrowser),
|
||||
_ => Err(format!("Unknown browser type: {}", s)),
|
||||
_ => Err(format!("Unknown browser type: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,10 +139,7 @@ impl Browser for FirefoxBrowser {
|
||||
.join(self.browser_type().as_str())
|
||||
.join(version);
|
||||
|
||||
println!(
|
||||
"Firefox browser checking version {} in directory: {:?}",
|
||||
version, browser_dir
|
||||
);
|
||||
println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
|
||||
|
||||
// Only check if directory exists and contains a .app file
|
||||
if browser_dir.exists() {
|
||||
@@ -158,7 +155,7 @@ impl Browser for FirefoxBrowser {
|
||||
}
|
||||
println!("No .app files found in directory");
|
||||
} else {
|
||||
println!("Directory does not exist: {:?}", browser_dir);
|
||||
println!("Directory does not exist: {browser_dir:?}");
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -229,8 +226,7 @@ impl Browser for ChromiumBrowser {
|
||||
let pac_content = fs::read(&pac_path)?;
|
||||
let pac_base64 = general_purpose::STANDARD.encode(&pac_content);
|
||||
args.push(format!(
|
||||
"--proxy-pac-url=data:application/x-javascript-config;base64,{}",
|
||||
pac_base64
|
||||
"--proxy-pac-url=data:application/x-javascript-config;base64,{pac_base64}"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -248,10 +244,7 @@ impl Browser for ChromiumBrowser {
|
||||
.join(self.browser_type().as_str())
|
||||
.join(version);
|
||||
|
||||
println!(
|
||||
"Chromium browser checking version {} in directory: {:?}",
|
||||
version, browser_dir
|
||||
);
|
||||
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() {
|
||||
@@ -273,7 +266,7 @@ impl Browser for ChromiumBrowser {
|
||||
}
|
||||
println!("No valid .app files found in directory");
|
||||
} else {
|
||||
println!("Directory does not exist: {:?}", browser_dir);
|
||||
println!("Directory does not exist: {browser_dir:?}");
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -310,6 +303,8 @@ pub struct GithubRelease {
|
||||
pub struct GithubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -567,7 +562,7 @@ mod tests {
|
||||
assert_eq!(browser_type, cloned);
|
||||
|
||||
// Test Debug trait
|
||||
let debug_str = format!("{:?}", browser_type);
|
||||
let debug_str = format!("{browser_type:?}");
|
||||
assert!(debug_str.contains("Firefox"));
|
||||
}
|
||||
|
||||
|
||||
+138
-165
@@ -83,6 +83,8 @@ impl BrowserRunner {
|
||||
}
|
||||
|
||||
// Helper function to validate PID for TOR/Mullvad browsers
|
||||
// TODO: make available for other platforms once other functionality is implemented
|
||||
#[cfg(target_os = "macos")]
|
||||
fn validate_tor_mullvad_pid(&self, profile: &BrowserProfile, pid: u32) -> bool {
|
||||
let system = System::new_all();
|
||||
|
||||
@@ -130,10 +132,11 @@ impl BrowserRunner {
|
||||
);
|
||||
true
|
||||
} else {
|
||||
println!("PID {} does not exist", pid);
|
||||
println!("PID {pid} does not exist");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
@@ -169,7 +172,7 @@ impl BrowserRunner {
|
||||
.iter()
|
||||
.any(|p| p.name.to_lowercase() == name.to_lowercase())
|
||||
{
|
||||
return Err(format!("Profile with name '{}' already exists", name).into());
|
||||
return Err(format!("Profile with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let snake_case_name = name.to_lowercase().replace(" ", "_");
|
||||
@@ -216,7 +219,7 @@ impl BrowserRunner {
|
||||
let profile_path = profiles_dir.join(profile_name.to_lowercase().replace(" ", "_"));
|
||||
|
||||
if !profile_file.exists() {
|
||||
return Err(format!("Profile {} not found", profile_name).into());
|
||||
return Err(format!("Profile {profile_name} not found").into());
|
||||
}
|
||||
|
||||
// Read the profile
|
||||
@@ -262,7 +265,7 @@ impl BrowserRunner {
|
||||
));
|
||||
|
||||
if !profile_file.exists() {
|
||||
return Err(format!("Profile {} not found", profile_name).into());
|
||||
return Err(format!("Profile {profile_name} not found").into());
|
||||
}
|
||||
|
||||
// Read the profile
|
||||
@@ -283,7 +286,7 @@ impl BrowserRunner {
|
||||
let binaries_dir = self.get_binaries_dir();
|
||||
|
||||
if !browser.is_version_downloaded(version, &binaries_dir) {
|
||||
return Err(format!("Browser version {} is not downloaded", version).into());
|
||||
return Err(format!("Browser version {version} is not downloaded").into());
|
||||
}
|
||||
|
||||
// Update version
|
||||
@@ -496,7 +499,7 @@ impl BrowserRunner {
|
||||
|
||||
// Try multiple times to find the process as it might take time to start
|
||||
for attempt in 1..=5 {
|
||||
println!("Attempt {} to find actual browser process...", attempt);
|
||||
println!("Attempt {attempt} to find actual browser process...");
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
@@ -605,7 +608,7 @@ impl BrowserRunner {
|
||||
let pid = updated_profile.process_id.unwrap();
|
||||
|
||||
// First try: Use Firefox remote command (most reliable for these browsers)
|
||||
println!("Trying Firefox remote command for PID: {}", pid);
|
||||
println!("Trying Firefox remote command for PID: {pid}");
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(&updated_profile.browser);
|
||||
browser_dir.push(&updated_profile.version);
|
||||
@@ -629,15 +632,11 @@ impl BrowserRunner {
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!(
|
||||
"Firefox remote command failed with stderr: {}, trying AppleScript fallback",
|
||||
stderr
|
||||
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Firefox remote command error: {}, trying AppleScript fallback",
|
||||
e
|
||||
);
|
||||
println!("Firefox remote command error: {e}, trying AppleScript fallback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,11 +652,11 @@ impl BrowserRunner {
|
||||
try
|
||||
tell application "System Events"
|
||||
-- Find the exact process by PID
|
||||
set targetProcess to (first application process whose unix id is {})
|
||||
set targetProcess to (first application process whose unix id is {pid})
|
||||
|
||||
-- Verify the process exists
|
||||
if not (exists targetProcess) then
|
||||
error "No process found with PID {}"
|
||||
error "No process found with PID {pid}"
|
||||
end if
|
||||
|
||||
-- Get the process name for verification
|
||||
@@ -700,35 +699,30 @@ try
|
||||
delay 0.5
|
||||
|
||||
-- Type the URL
|
||||
keystroke "{}"
|
||||
keystroke "{escaped_url}"
|
||||
delay 0.5
|
||||
|
||||
-- Press Enter to navigate
|
||||
keystroke return
|
||||
end tell
|
||||
|
||||
return "Successfully opened URL in " & processName & " (PID: {})"
|
||||
return "Successfully opened URL in " & processName & " (PID: {pid})"
|
||||
end tell
|
||||
on error errMsg number errNum
|
||||
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
|
||||
end try
|
||||
"#,
|
||||
pid, pid, escaped_url, pid
|
||||
"#
|
||||
);
|
||||
|
||||
println!(
|
||||
"Executing AppleScript fallback for Firefox-based browser (PID: {})...",
|
||||
pid
|
||||
);
|
||||
println!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
|
||||
let output = Command::new("osascript").args(["-e", &script]).output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
println!("AppleScript failed: {}", error_msg);
|
||||
println!("AppleScript failed: {error_msg}");
|
||||
return Err(
|
||||
format!(
|
||||
"Both Firefox remote command and AppleScript failed. AppleScript error: {}",
|
||||
error_msg
|
||||
"Both Firefox remote command and AppleScript failed. AppleScript error: {error_msg}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
@@ -770,10 +764,7 @@ end try
|
||||
{
|
||||
let pid = updated_profile.process_id.unwrap();
|
||||
|
||||
println!(
|
||||
"Opening URL in TOR/Mullvad browser using file-based approach (PID: {})",
|
||||
pid
|
||||
);
|
||||
println!("Opening URL in TOR/Mullvad browser using file-based approach (PID: {pid})");
|
||||
|
||||
// Validate that we have the correct PID for this TOR/Mullvad browser
|
||||
if !self.validate_tor_mullvad_pid(&updated_profile, pid) {
|
||||
@@ -799,23 +790,22 @@ end try
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="0; url={}">
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
window.location.href = "{}";
|
||||
window.location.href = "{url}";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="{}">{}</a>...</p>
|
||||
<p>Redirecting to <a href="{url}">{url}</a>...</p>
|
||||
</body>
|
||||
</html>"#,
|
||||
url, url, url, url
|
||||
</html>"#
|
||||
);
|
||||
|
||||
// Write the HTML file
|
||||
match std::fs::write(&temp_file_path, html_content) {
|
||||
Ok(()) => {
|
||||
println!("Created temporary HTML file: {:?}", temp_file_path);
|
||||
println!("Created temporary HTML file: {temp_file_path:?}");
|
||||
|
||||
// Get the browser executable path to use with 'open'
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
@@ -848,10 +838,10 @@ end try
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("File-based approach failed: {}", stderr);
|
||||
println!("File-based approach failed: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("File-based approach error: {}", e);
|
||||
println!("File-based approach error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -860,7 +850,7 @@ end try
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to create temporary HTML file: {}", e);
|
||||
println!("Failed to create temporary HTML file: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,10 +876,10 @@ end try
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("Direct 'open' command failed: {}", stderr);
|
||||
println!("Direct 'open' command failed: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Direct 'open' command error: {}", e);
|
||||
println!("Direct 'open' command error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,9 +891,9 @@ end try
|
||||
r#"
|
||||
try
|
||||
tell application "System Events"
|
||||
set targetProcess to (first application process whose unix id is {})
|
||||
set targetProcess to (first application process whose unix id is {pid})
|
||||
if not (exists targetProcess) then
|
||||
error "No process found with PID {}"
|
||||
error "No process found with PID {pid}"
|
||||
end if
|
||||
|
||||
-- Just bring the process to front without trying to control it
|
||||
@@ -914,8 +904,7 @@ try
|
||||
on error errMsg
|
||||
return "Minimal AppleScript failed: " & errMsg
|
||||
end try
|
||||
"#,
|
||||
pid, pid
|
||||
"#
|
||||
);
|
||||
|
||||
let minimal_output = Command::new("osascript")
|
||||
@@ -926,7 +915,7 @@ end try
|
||||
Ok(output) => {
|
||||
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if output.status.success() && result.contains("successfully") {
|
||||
println!("Successfully brought browser to front: {}", result);
|
||||
println!("Successfully brought browser to front: {result}");
|
||||
|
||||
// Now try to use the system's default URL opening mechanism
|
||||
let system_open_result = Command::new("open").args([url]).output();
|
||||
@@ -938,31 +927,30 @@ end try
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("System default URL opening failed: {}", stderr);
|
||||
println!("System default URL opening failed: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("System default URL opening error: {}", e);
|
||||
println!("System default URL opening error: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Minimal AppleScript failed: {}", result);
|
||||
println!("Minimal AppleScript failed: {result}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Minimal AppleScript execution error: {}", e);
|
||||
println!("Minimal AppleScript execution error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// If all methods fail, return a more helpful error message
|
||||
return Err(format!(
|
||||
"Failed to open URL in existing TOR/Mullvad browser (PID: {}). All methods failed:\n\
|
||||
"Failed to open URL in existing TOR/Mullvad browser (PID: {pid}). All methods failed:\n\
|
||||
1. File-based approach failed\n\
|
||||
2. Direct 'open' command failed\n\
|
||||
3. Minimal AppleScript approach failed\n\
|
||||
\n\
|
||||
This may be due to browser security restrictions or the browser process may have changed.\n\
|
||||
Try closing and reopening the browser, or manually paste the URL: {}",
|
||||
pid, url
|
||||
Try closing and reopening the browser, or manually paste the URL: {url}"
|
||||
).into());
|
||||
}
|
||||
|
||||
@@ -980,7 +968,7 @@ end try
|
||||
let pid = updated_profile.process_id.unwrap();
|
||||
|
||||
// First, try using the browser's built-in URL opening capability
|
||||
println!("Trying Chromium URL opening for PID: {}", pid);
|
||||
println!("Trying Chromium URL opening for PID: {pid}");
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(&updated_profile.browser);
|
||||
browser_dir.push(&updated_profile.version);
|
||||
@@ -1002,13 +990,10 @@ end try
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!(
|
||||
"Chromium URL opening failed: {}, trying AppleScript",
|
||||
stderr
|
||||
);
|
||||
println!("Chromium URL opening failed: {stderr}, trying AppleScript");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Chromium URL opening error: {}, trying AppleScript", e);
|
||||
println!("Chromium URL opening error: {e}, trying AppleScript");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1024,11 +1009,11 @@ end try
|
||||
try
|
||||
tell application "System Events"
|
||||
-- Find the exact process by PID
|
||||
set targetProcess to (first application process whose unix id is {})
|
||||
set targetProcess to (first application process whose unix id is {pid})
|
||||
|
||||
-- Verify the process exists
|
||||
if not (exists targetProcess) then
|
||||
error "No process found with PID {}"
|
||||
error "No process found with PID {pid}"
|
||||
end if
|
||||
|
||||
-- Get the process name for verification
|
||||
@@ -1071,37 +1056,29 @@ try
|
||||
delay 0.5
|
||||
|
||||
-- Type the URL
|
||||
keystroke "{}"
|
||||
keystroke "{escaped_url}"
|
||||
delay 0.5
|
||||
|
||||
-- Press Enter to navigate
|
||||
keystroke return
|
||||
end tell
|
||||
|
||||
return "Successfully opened URL in " & processName & " (PID: {})"
|
||||
return "Successfully opened URL in " & processName & " (PID: {pid})"
|
||||
end tell
|
||||
on error errMsg number errNum
|
||||
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
|
||||
end try
|
||||
"#,
|
||||
pid, pid, escaped_url, pid
|
||||
"#
|
||||
);
|
||||
|
||||
println!(
|
||||
"Executing AppleScript for Chromium-based browser (PID: {})...",
|
||||
pid
|
||||
);
|
||||
println!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
|
||||
let output = Command::new("osascript").args(["-e", &script]).output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
println!("AppleScript failed: {}", error_msg);
|
||||
println!("AppleScript failed: {error_msg}");
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing Chromium-based browser: {}",
|
||||
error_msg
|
||||
)
|
||||
.into(),
|
||||
format!("Failed to open URL in existing Chromium-based browser: {error_msg}").into(),
|
||||
);
|
||||
} else {
|
||||
println!("AppleScript succeeded");
|
||||
@@ -1176,49 +1153,57 @@ end try
|
||||
|
||||
if is_running && url.is_some() {
|
||||
// Browser is running and we have a URL to open
|
||||
println!("Opening URL in existing browser: {}", url.as_ref().unwrap());
|
||||
if let Some(url_ref) = url.as_ref() {
|
||||
println!("Opening URL in existing browser: {url_ref}");
|
||||
|
||||
// For TOR/Mullvad browsers, add extra verification
|
||||
if matches!(
|
||||
final_profile.browser.as_str(),
|
||||
"tor-browser" | "mullvad-browser"
|
||||
) {
|
||||
println!("TOR/Mullvad browser detected - ensuring we have correct PID");
|
||||
if final_profile.process_id.is_none() {
|
||||
println!("ERROR: No PID found for running TOR/Mullvad browser - this should not happen");
|
||||
return Err("No PID found for running browser".into());
|
||||
// For TOR/Mullvad browsers, add extra verification
|
||||
if matches!(
|
||||
final_profile.browser.as_str(),
|
||||
"tor-browser" | "mullvad-browser"
|
||||
) {
|
||||
println!("TOR/Mullvad browser detected - ensuring we have correct PID");
|
||||
if final_profile.process_id.is_none() {
|
||||
println!(
|
||||
"ERROR: No PID found for running TOR/Mullvad browser - this should not happen"
|
||||
);
|
||||
return Err("No PID found for running browser".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
match self
|
||||
.open_url_in_existing_browser(app_handle, &final_profile, url.as_ref().unwrap())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
println!("Successfully opened URL in existing browser");
|
||||
Ok(final_profile)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to open URL in existing browser: {}", e);
|
||||
match self
|
||||
.open_url_in_existing_browser(app_handle, &final_profile, url_ref)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
println!("Successfully opened URL in existing browser");
|
||||
Ok(final_profile)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to open URL in existing browser: {e}");
|
||||
|
||||
// For Mullvad and Tor browsers, don't fall back to new instance since they use -no-remote
|
||||
// and can't have multiple instances with the same profile
|
||||
match final_profile.browser.as_str() {
|
||||
"mullvad-browser" | "tor-browser" => {
|
||||
Err(format!(
|
||||
"Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}",
|
||||
final_profile.browser, e
|
||||
).into())
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Falling back to new instance for browser: {}",
|
||||
final_profile.browser
|
||||
);
|
||||
// Fallback to launching a new instance for other browsers
|
||||
self.launch_browser(&final_profile, url).await
|
||||
// For Mullvad and Tor browsers, don't fall back to new instance since they use -no-remote
|
||||
// and can't have multiple instances with the same profile
|
||||
match final_profile.browser.as_str() {
|
||||
"mullvad-browser" | "tor-browser" => {
|
||||
Err(format!(
|
||||
"Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}",
|
||||
final_profile.browser, e
|
||||
).into())
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Falling back to new instance for browser: {}",
|
||||
final_profile.browser
|
||||
);
|
||||
// Fallback to launching a new instance for other browsers
|
||||
self.launch_browser(&final_profile, url).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This case shouldn't happen since we checked is_some() above, but handle it gracefully
|
||||
println!("URL was unexpectedly None, launching new browser instance");
|
||||
self.launch_browser(&final_profile, url).await
|
||||
}
|
||||
} else {
|
||||
// Browser is not running or no URL provided, launch new instance
|
||||
@@ -1249,7 +1234,7 @@ end try
|
||||
.iter()
|
||||
.any(|p| p.name.to_lowercase() == new_name.to_lowercase())
|
||||
{
|
||||
return Err(format!("Profile with name '{}' already exists", new_name).into());
|
||||
return Err(format!("Profile with name '{new_name}' already exists").into());
|
||||
}
|
||||
|
||||
// Read the profile
|
||||
@@ -1400,13 +1385,10 @@ end try
|
||||
pid, profile.name
|
||||
);
|
||||
} else {
|
||||
println!("PID {} exists but doesn't match our profile path exactly, searching for correct process...", pid);
|
||||
println!("PID {pid} exists but doesn't match our profile path exactly, searching for correct process...");
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"Stored PID {} no longer exists, searching for browser process...",
|
||||
pid
|
||||
);
|
||||
println!("Stored PID {pid} no longer exists, searching for browser process...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,7 +1462,7 @@ end try
|
||||
if inner_profile.process_id != Some(pid) {
|
||||
inner_profile.process_id = Some(pid);
|
||||
if let Err(e) = self.save_process_info(&inner_profile) {
|
||||
println!("Warning: Failed to update process info: {}", e);
|
||||
println!("Warning: Failed to update process info: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Updated process ID for profile '{}' to: {}",
|
||||
@@ -1495,7 +1477,7 @@ end try
|
||||
if inner_profile.process_id.is_some() {
|
||||
inner_profile.process_id = None;
|
||||
if let Err(e) = self.save_process_info(&inner_profile) {
|
||||
println!("Warning: Failed to clear process info: {}", e);
|
||||
println!("Warning: Failed to clear process info: {e}");
|
||||
} else {
|
||||
println!("Cleared process ID for profile '{}'", inner_profile.name);
|
||||
}
|
||||
@@ -1616,11 +1598,11 @@ end try
|
||||
found_pid.ok_or("Browser process not found")?
|
||||
};
|
||||
|
||||
println!("Attempting to kill browser process with PID: {}", pid);
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
// Stop any associated proxy first
|
||||
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
|
||||
println!("Warning: Failed to stop proxy for PID {}: {}", pid, e);
|
||||
println!("Warning: Failed to stop proxy for PID {pid}: {e}");
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
@@ -1632,7 +1614,7 @@ end try
|
||||
let output = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute kill command: {}", e))?;
|
||||
.map_err(|e| format!("Failed to execute kill command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// If SIGTERM fails, try SIGKILL (force kill)
|
||||
@@ -1671,9 +1653,9 @@ end try
|
||||
updated_profile.process_id = None;
|
||||
self
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {}", e))?;
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
|
||||
println!("Successfully killed browser process with PID: {}", pid);
|
||||
println!("Successfully killed browser process with PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1688,7 +1670,7 @@ pub fn create_browser_profile(
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.create_profile(&name, &browser, &version, proxy)
|
||||
.map_err(|e| format!("Failed to create profile: {}", e))
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1696,7 +1678,7 @@ pub fn list_browser_profiles() -> Result<Vec<BrowserProfile>, String> {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {}", e))
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1734,10 +1716,10 @@ pub async fn launch_browser_profile(
|
||||
// Apply the proxy settings with the internal proxy
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy, Some(&internal_proxy_settings))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {}", e))?;
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {}", e);
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Continue without proxy
|
||||
}
|
||||
}
|
||||
@@ -1765,7 +1747,7 @@ pub fn update_profile_proxy(
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.update_profile_proxy(&profile_name, proxy)
|
||||
.map_err(|e| format!("Failed to update profile: {}", e))
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1776,7 +1758,7 @@ pub fn update_profile_version(
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.update_profile_version(&profile_name, &version)
|
||||
.map_err(|e| format!("Failed to update profile version: {}", e))
|
||||
.map_err(|e| format!("Failed to update profile version: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1788,7 +1770,7 @@ pub async fn check_browser_status(
|
||||
browser_runner
|
||||
.check_browser_status(app_handle, &profile)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check browser status: {}", e))
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1800,7 +1782,7 @@ pub fn rename_profile(
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.rename_profile(old_name, new_name)
|
||||
.map_err(|e| format!("Failed to delete profile: {}", e))
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1808,7 +1790,7 @@ pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Re
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.delete_profile(profile_name.as_str())
|
||||
.map_err(|e| format!("Failed to delete profile: {}", e))
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1832,7 +1814,7 @@ pub async fn fetch_browser_versions_detailed(
|
||||
service
|
||||
.fetch_browser_versions_detailed(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch detailed browser versions: {}", e))
|
||||
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1853,10 +1835,7 @@ pub async fn fetch_browser_versions_cached_first(
|
||||
.fetch_browser_versions_detailed(&browser_str_clone, false)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"Background version update failed for {}: {}",
|
||||
browser_str_clone, e
|
||||
);
|
||||
eprintln!("Background version update failed for {browser_str_clone}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1866,7 +1845,7 @@ pub async fn fetch_browser_versions_cached_first(
|
||||
service
|
||||
.fetch_browser_versions_detailed(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch detailed browser versions: {}", e))
|
||||
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1888,10 +1867,7 @@ pub async fn fetch_browser_versions_with_count_cached_first(
|
||||
.fetch_browser_versions_with_count(&browser_str_clone, false)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"Background version update failed for {}: {}",
|
||||
browser_str_clone, e
|
||||
);
|
||||
eprintln!("Background version update failed for {browser_str_clone}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1907,7 +1883,7 @@ pub async fn fetch_browser_versions_with_count_cached_first(
|
||||
service
|
||||
.fetch_browser_versions_with_count(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {}", e))
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1933,12 +1909,12 @@ pub async fn download_browser(
|
||||
) -> Result<String, String> {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {}", e))?;
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// Load registry and check if already downloaded
|
||||
let mut registry = DownloadedBrowsersRegistry::load()
|
||||
.map_err(|e| format!("Failed to load browser registry: {}", e))?;
|
||||
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
|
||||
|
||||
if registry.is_browser_downloaded(&browser_str, &version) {
|
||||
return Ok(version);
|
||||
@@ -1948,7 +1924,7 @@ pub async fn download_browser(
|
||||
let version_service = BrowserVersionService::new();
|
||||
let download_info = version_service
|
||||
.get_download_info(&browser_str, &version)
|
||||
.map_err(|e| format!("Failed to get download info: {}", e))?;
|
||||
.map_err(|e| format!("Failed to get download info: {e}"))?;
|
||||
|
||||
// Create browser directory
|
||||
let mut browser_dir = browser_runner.get_binaries_dir();
|
||||
@@ -1957,16 +1933,16 @@ pub async fn download_browser(
|
||||
|
||||
// Clean up any failed previous download
|
||||
if let Err(e) = registry.cleanup_failed_download(&browser_str, &version) {
|
||||
println!("Warning: Failed to cleanup previous download: {}", e);
|
||||
println!("Warning: Failed to cleanup previous download: {e}");
|
||||
}
|
||||
|
||||
create_dir_all(&browser_dir).map_err(|e| format!("Failed to create browser directory: {}", e))?;
|
||||
create_dir_all(&browser_dir).map_err(|e| format!("Failed to create browser directory: {e}"))?;
|
||||
|
||||
// Mark download as started in registry
|
||||
registry.mark_download_started(&browser_str, &version, browser_dir.clone());
|
||||
registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save registry: {}", e))?;
|
||||
.map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
|
||||
// Use the new download module
|
||||
let downloader = Downloader::new();
|
||||
@@ -1985,7 +1961,7 @@ pub async fn download_browser(
|
||||
// Clean up failed download
|
||||
let _ = registry.cleanup_failed_download(&browser_str, &version);
|
||||
let _ = registry.save();
|
||||
return Err(format!("Failed to download browser: {}", e));
|
||||
return Err(format!("Failed to download browser: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2005,14 +1981,14 @@ pub async fn download_browser(
|
||||
Ok(_) => {
|
||||
// Clean up the downloaded archive
|
||||
if let Err(e) = std::fs::remove_file(&download_path) {
|
||||
println!("Warning: Could not delete archive file: {}", e);
|
||||
println!("Warning: Could not delete archive file: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Clean up failed download
|
||||
let _ = registry.cleanup_failed_download(&browser_str, &version);
|
||||
let _ = registry.save();
|
||||
return Err(format!("Failed to extract browser: {}", e));
|
||||
return Err(format!("Failed to extract browser: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2034,10 +2010,7 @@ pub async fn download_browser(
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
|
||||
// Verify the browser was downloaded correctly
|
||||
println!(
|
||||
"Verifying download for browser: {}, version: {}",
|
||||
browser_str, version
|
||||
);
|
||||
println!("Verifying download for browser: {browser_str}, version: {version}");
|
||||
|
||||
// Use the browser's own verification method
|
||||
let binaries_dir = browser_runner.get_binaries_dir();
|
||||
@@ -2056,10 +2029,10 @@ pub async fn download_browser(
|
||||
|
||||
registry
|
||||
.mark_download_completed_with_actual_version(&browser_str, &version, actual_version)
|
||||
.map_err(|e| format!("Failed to mark download as completed: {}", e))?;
|
||||
.map_err(|e| format!("Failed to mark download as completed: {e}"))?;
|
||||
registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save registry: {}", e))?;
|
||||
.map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
|
||||
// Emit completion
|
||||
let progress = DownloadProgress {
|
||||
@@ -2106,7 +2079,7 @@ pub async fn kill_browser_profile(
|
||||
browser_runner
|
||||
.kill_browser_process(app_handle, &profile)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to kill browser: {}", e))
|
||||
.map_err(|e| format!("Failed to kill browser: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2117,7 +2090,7 @@ pub fn create_browser_profile_new(
|
||||
proxy: Option<ProxySettings>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {}", e))?;
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
create_browser_profile(name, browser_type.as_str().to_string(), version, proxy)
|
||||
}
|
||||
|
||||
@@ -2127,7 +2100,7 @@ pub async fn fetch_browser_versions(browser_str: String) -> Result<Vec<String>,
|
||||
service
|
||||
.fetch_browser_versions(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {}", e))
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2138,13 +2111,13 @@ pub async fn fetch_browser_versions_with_count(
|
||||
service
|
||||
.fetch_browser_versions_with_count(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {}", e))
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String>, String> {
|
||||
let registry = DownloadedBrowsersRegistry::load()
|
||||
.map_err(|e| format!("Failed to load browser registry: {}", e))?;
|
||||
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
|
||||
Ok(registry.get_downloaded_versions(&browser_str))
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ impl BrowserVersionService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
Self { api_client }
|
||||
}
|
||||
|
||||
/// Get cached browser versions immediately (returns None if no cache exists)
|
||||
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
|
||||
self.api_client.load_cached_versions(browser)
|
||||
@@ -101,7 +106,7 @@ impl BrowserVersionService {
|
||||
"brave" => self.fetch_brave_versions(true).await?,
|
||||
"chromium" => self.fetch_chromium_versions(true).await?,
|
||||
"tor-browser" => self.fetch_tor_versions(true).await?,
|
||||
_ => return Err(format!("Unsupported browser: {}", browser).into()),
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
};
|
||||
|
||||
let fresh_set: HashSet<String> = fresh_versions.into_iter().collect();
|
||||
@@ -126,7 +131,7 @@ impl BrowserVersionService {
|
||||
.api_client
|
||||
.save_cached_versions(browser, &merged_versions)
|
||||
{
|
||||
eprintln!("Failed to save merged cache for {}: {}", browser, e);
|
||||
eprintln!("Failed to save merged cache for {browser}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ impl BrowserVersionService {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser: {}", browser).into()),
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
};
|
||||
|
||||
Ok(detailed_info)
|
||||
@@ -338,7 +343,7 @@ impl BrowserVersionService {
|
||||
|
||||
// Save the updated cache
|
||||
if let Err(e) = self.api_client.save_cached_versions(browser, &all_versions) {
|
||||
eprintln!("Failed to save updated cache for {}: {}", browser, e);
|
||||
eprintln!("Failed to save updated cache for {browser}: {e}");
|
||||
}
|
||||
|
||||
Ok(new_versions_count)
|
||||
@@ -352,29 +357,27 @@ impl BrowserVersionService {
|
||||
) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match browser {
|
||||
"firefox" => Ok(DownloadInfo {
|
||||
url: format!("https://download.mozilla.org/?product=firefox-{}&os=osx&lang=en-US", version),
|
||||
filename: format!("firefox-{}.dmg", version),
|
||||
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"),
|
||||
filename: format!("firefox-{version}.dmg"),
|
||||
is_archive: true,
|
||||
}),
|
||||
"firefox-developer" => Ok(DownloadInfo {
|
||||
url: format!("https://download.mozilla.org/?product=devedition-{}&os=osx&lang=en-US", version),
|
||||
filename: format!("firefox-developer-{}.dmg", version),
|
||||
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"),
|
||||
filename: format!("firefox-developer-{version}.dmg"),
|
||||
is_archive: true,
|
||||
}),
|
||||
"mullvad-browser" => Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/mullvad/mullvad-browser/releases/download/{}/mullvad-browser-macos-{}.dmg",
|
||||
version, version
|
||||
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-macos-{version}.dmg"
|
||||
),
|
||||
filename: format!("mullvad-browser-{}.dmg", version),
|
||||
filename: format!("mullvad-browser-{version}.dmg"),
|
||||
is_archive: true,
|
||||
}),
|
||||
"zen" => Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/zen-browser/desktop/releases/download/{}/zen.macos-universal.dmg",
|
||||
version
|
||||
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.macos-universal.dmg"
|
||||
),
|
||||
filename: format!("zen-{}.dmg", version),
|
||||
filename: format!("zen-{version}.dmg"),
|
||||
is_archive: true,
|
||||
}),
|
||||
"brave" => {
|
||||
@@ -382,10 +385,9 @@ impl BrowserVersionService {
|
||||
// The actual URL will be resolved in the download service using the GitHub API
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/brave/brave-browser/releases/download/{}/Brave-Browser-universal.dmg",
|
||||
version
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/Brave-Browser-universal.dmg"
|
||||
),
|
||||
filename: format!("brave-{}.dmg", version),
|
||||
filename: format!("brave-{version}.dmg"),
|
||||
is_archive: true,
|
||||
})
|
||||
}
|
||||
@@ -393,22 +395,20 @@ impl BrowserVersionService {
|
||||
let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" };
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{}/{}/chrome-mac.zip",
|
||||
arch, version
|
||||
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/{version}/chrome-mac.zip"
|
||||
),
|
||||
filename: format!("chromium-{}.zip", version),
|
||||
filename: format!("chromium-{version}.zip"),
|
||||
is_archive: true,
|
||||
})
|
||||
}
|
||||
"tor-browser" => Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://archive.torproject.org/tor-package-archive/torbrowser/{}/tor-browser-macos-{}.dmg",
|
||||
version, version
|
||||
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
|
||||
),
|
||||
filename: format!("tor-browser-{}.dmg", version),
|
||||
filename: format!("tor-browser-{version}.dmg"),
|
||||
is_archive: true,
|
||||
}),
|
||||
_ => Err(format!("Unsupported browser: {}", browser).into()),
|
||||
_ => Err(format!("Unsupported browser: {browser}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +546,335 @@ impl BrowserVersionService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
fn create_test_service(api_client: ApiClient) -> BrowserVersionService {
|
||||
BrowserVersionService::new_with_api_client(api_client)
|
||||
}
|
||||
|
||||
async fn setup_firefox_mocks(server: &MockServer) {
|
||||
let mock_response = r#"{
|
||||
"releases": {
|
||||
"firefox-139.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-15",
|
||||
"description": "Firefox 139.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "139.0"
|
||||
},
|
||||
"firefox-138.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-01",
|
||||
"description": "Firefox 138.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "138.0"
|
||||
},
|
||||
"firefox-137.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2023-12-15",
|
||||
"description": "Firefox 137.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "137.0"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/firefox.json"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_firefox_dev_mocks(server: &MockServer) {
|
||||
let mock_response = r#"{
|
||||
"releases": {
|
||||
"devedition-140.0b1": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-20",
|
||||
"description": "Firefox Developer Edition 140.0b1",
|
||||
"is_security_driven": false,
|
||||
"product": "devedition",
|
||||
"version": "140.0b1"
|
||||
},
|
||||
"devedition-139.0b5": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-10",
|
||||
"description": "Firefox Developer Edition 139.0b5",
|
||||
"is_security_driven": false,
|
||||
"product": "devedition",
|
||||
"version": "139.0b5"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/devedition.json"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_mullvad_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
|
||||
"size": 100000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "14.5a5",
|
||||
"name": "Mullvad Browser 14.5a5",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a5.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a5.dmg",
|
||||
"size": 99000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_zen_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"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",
|
||||
"size": 120000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b.dmg",
|
||||
"size": 115000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_brave_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
|
||||
"size": 199000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_chromium_mocks(server: &MockServer) {
|
||||
let arch = if cfg!(target_arch = "aarch64") {
|
||||
"Mac_Arm"
|
||||
} else {
|
||||
"Mac"
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{arch}/LAST_CHANGE")))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("1465660")
|
||||
.insert_header("content-type", "text/plain"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_tor_mocks(server: &MockServer) {
|
||||
let mock_html = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="../">../</a>
|
||||
<a href="14.0.4/">14.0.4/</a>
|
||||
<a href="14.0.3/">14.0.3/</a>
|
||||
<a href="14.0.2/">14.0.2/</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_144 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_143 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.3.dmg">tor-browser-macos-14.0.3.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_142 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.2.dmg">tor-browser-macos-14.0.2.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_html)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.4/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_144)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.3/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_143)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.2/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_142)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_browser_version_service_creation() {
|
||||
@@ -555,7 +884,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_firefox_versions() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test with caching
|
||||
let result_cached = service.fetch_browser_versions("firefox", false).await;
|
||||
@@ -566,15 +899,13 @@ mod tests {
|
||||
|
||||
if let Ok(versions) = result_cached {
|
||||
assert!(!versions.is_empty(), "Should have Firefox versions");
|
||||
assert_eq!(versions[0], "139.0", "Should have latest version first");
|
||||
println!(
|
||||
"Firefox cached test passed. Found {} versions",
|
||||
versions.len()
|
||||
"Firefox cached test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test without caching
|
||||
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
|
||||
assert!(
|
||||
@@ -587,16 +918,21 @@ mod tests {
|
||||
!versions.is_empty(),
|
||||
"Should have Firefox versions without caching"
|
||||
);
|
||||
assert_eq!(versions[0], "139.0", "Should have latest version first");
|
||||
println!(
|
||||
"Firefox no-cache test passed. Found {} versions",
|
||||
versions.len()
|
||||
"Firefox no-cache test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_browser_versions_with_count() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service
|
||||
.fetch_browser_versions_with_count("firefox", false)
|
||||
@@ -610,16 +946,25 @@ mod tests {
|
||||
result.versions.len(),
|
||||
"Total count should match versions length"
|
||||
);
|
||||
assert_eq!(
|
||||
result.versions[0], "139.0",
|
||||
"Should have latest version first"
|
||||
);
|
||||
println!(
|
||||
"Firefox count test passed. Found {} versions, new: {:?}",
|
||||
result.total_versions_count, result.new_versions_count
|
||||
"Firefox count test passed. Found {} versions, new: {}",
|
||||
result.total_versions_count,
|
||||
result.new_versions_count.unwrap_or(0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_detailed_versions() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service
|
||||
.fetch_browser_versions_detailed("firefox", false)
|
||||
@@ -635,16 +980,24 @@ mod tests {
|
||||
!first_version.version.is_empty(),
|
||||
"Version should not be empty"
|
||||
);
|
||||
assert_eq!(
|
||||
first_version.version, "139.0",
|
||||
"Should have latest version first"
|
||||
);
|
||||
assert_eq!(first_version.date, "2024-01-15", "Should have correct date");
|
||||
assert!(!first_version.is_prerelease, "Should be stable release");
|
||||
println!(
|
||||
"Firefox detailed test passed. Found {} detailed versions",
|
||||
versions.len()
|
||||
"Firefox detailed test passed. Found {versions_count} detailed versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_browser() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service.fetch_browser_versions("unsupported", false).await;
|
||||
assert!(
|
||||
@@ -662,7 +1015,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incremental_update() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// This test might fail if there are no cached versions yet, which is fine
|
||||
let result = service
|
||||
@@ -672,24 +1029,30 @@ mod tests {
|
||||
// The test should complete without panicking
|
||||
match result {
|
||||
Ok(count) => {
|
||||
println!(
|
||||
"Incremental update test passed. Found {} new versions",
|
||||
count
|
||||
);
|
||||
println!("Incremental update test passed. Found {count} new versions");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Incremental update test failed (expected for first run): {}",
|
||||
e
|
||||
);
|
||||
// Don't fail the test, as this is expected behavior for first run
|
||||
println!("Incremental update test failed (expected for first run): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_supported_browsers() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
|
||||
// Setup all browser mocks
|
||||
setup_firefox_mocks(&server).await;
|
||||
setup_firefox_dev_mocks(&server).await;
|
||||
setup_mullvad_mocks(&server).await;
|
||||
setup_zen_mocks(&server).await;
|
||||
setup_brave_mocks(&server).await;
|
||||
setup_chromium_mocks(&server).await;
|
||||
setup_tor_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let browsers = vec![
|
||||
"firefox",
|
||||
"firefox-developer",
|
||||
@@ -701,27 +1064,30 @@ mod tests {
|
||||
];
|
||||
|
||||
for browser in browsers {
|
||||
// Test that we can at least call the function without panicking
|
||||
let result = service.fetch_browser_versions(browser, false).await;
|
||||
|
||||
match result {
|
||||
Ok(versions) => {
|
||||
println!("{} test passed. Found {} versions", browser, versions.len());
|
||||
assert!(!versions.is_empty(), "Should have versions for {browser}");
|
||||
println!(
|
||||
"{browser} test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Some browsers might fail due to network issues, but shouldn't panic
|
||||
println!("{} test failed (network issue): {}", browser, e);
|
||||
panic!("{browser} test failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between requests to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_caching_parameter() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test with caching enabled (default)
|
||||
let result_cached = service.fetch_browser_versions("firefox", false).await;
|
||||
@@ -730,9 +1096,6 @@ mod tests {
|
||||
"Should fetch Firefox versions with caching"
|
||||
);
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test with caching disabled (no_caching = true)
|
||||
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
|
||||
assert!(
|
||||
@@ -750,6 +1113,10 @@ mod tests {
|
||||
!no_cache_versions.is_empty(),
|
||||
"No-cache versions should not be empty"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_versions, no_cache_versions,
|
||||
"Both should return same versions"
|
||||
);
|
||||
println!(
|
||||
"No-caching test passed. Cached: {} versions, No-cache: {} versions",
|
||||
cached_versions.len(),
|
||||
@@ -760,7 +1127,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detailed_versions_with_no_caching() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test detailed versions with caching
|
||||
let result_cached = service
|
||||
@@ -771,9 +1142,6 @@ mod tests {
|
||||
"Should fetch detailed Firefox versions with caching"
|
||||
);
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test detailed versions without caching
|
||||
let result_no_cache = service
|
||||
.fetch_browser_versions_detailed("firefox", true)
|
||||
@@ -807,6 +1175,17 @@ mod tests {
|
||||
"No-cache version should not be empty"
|
||||
);
|
||||
|
||||
assert_eq!(first_cached.version, "139.0", "Should have correct version");
|
||||
assert_eq!(
|
||||
first_no_cache.version, "139.0",
|
||||
"Should have correct version"
|
||||
);
|
||||
assert_eq!(first_cached.date, "2024-01-15", "Should have correct date");
|
||||
assert_eq!(
|
||||
first_no_cache.date, "2024-01-15",
|
||||
"Should have correct date"
|
||||
);
|
||||
|
||||
println!(
|
||||
"Detailed no-caching test passed. Cached: {} versions, No-cache: {} versions",
|
||||
cached_versions.len(),
|
||||
|
||||
@@ -49,12 +49,10 @@ mod macos {
|
||||
if status != 0 {
|
||||
let error_msg = match status {
|
||||
-54 => format!(
|
||||
"Failed to set as default browser for scheme '{}'. The app is not properly registered as a browser. Please:\n1. Build and install the app properly\n2. Manually set Donut Browser as default in System Settings > General > Default web browser\n3. Make sure the app is in your Applications folder",
|
||||
scheme
|
||||
"Failed to set as default browser for scheme '{scheme}'. The app is not properly registered as a browser. Please:\n1. Build and install the app properly\n2. Manually set Donut Browser as default in System Settings > General > Default web browser\n3. Make sure the app is in your Applications folder"
|
||||
),
|
||||
_ => format!(
|
||||
"Failed to set as default browser for scheme '{}'. Status code: {}. Please manually set Donut Browser as default in System Settings > General > Default web browser.",
|
||||
scheme, status
|
||||
"Failed to set as default browser for scheme '{scheme}'. Status code: {status}. Please manually set Donut Browser as default in System Settings > General > Default web browser."
|
||||
)
|
||||
};
|
||||
return Err(error_msg);
|
||||
@@ -132,34 +130,31 @@ pub async fn open_url_with_profile(
|
||||
// Get the profile by name
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {}", e))?;
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
|
||||
println!("Opening URL '{}' with profile '{}'", url, profile_name);
|
||||
println!("Opening URL '{url}' with profile '{profile_name}'");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
runner
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("Failed to open URL with profile '{}': {}", profile_name, e);
|
||||
format!("Failed to open URL with profile: {}", e)
|
||||
println!("Failed to open URL with profile '{profile_name}': {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
|
||||
println!(
|
||||
"Successfully opened URL '{}' with profile '{}'",
|
||||
url, profile_name
|
||||
);
|
||||
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -169,17 +164,82 @@ pub async fn smart_open_url(
|
||||
// Get all profiles
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {}", e))?;
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
if profiles.is_empty() {
|
||||
return Err("no_profiles".to_string());
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
+531
-89
@@ -34,6 +34,14 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the actual download URL for browsers that need dynamic asset resolution
|
||||
pub async fn resolve_download_url(
|
||||
&self,
|
||||
@@ -44,7 +52,10 @@ impl Downloader {
|
||||
match browser_type {
|
||||
BrowserType::Brave => {
|
||||
// For Brave, we need to find the actual macOS asset
|
||||
let releases = self.api_client.fetch_brave_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_brave_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Find the release with the matching version
|
||||
let release = releases
|
||||
@@ -52,7 +63,7 @@ impl Downloader {
|
||||
.find(|r| {
|
||||
r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v'))
|
||||
})
|
||||
.ok_or(format!("Brave version {} not found", version))?;
|
||||
.ok_or(format!("Brave version {version} not found"))?;
|
||||
|
||||
// Find the universal macOS DMG asset
|
||||
let asset = release
|
||||
@@ -60,20 +71,22 @@ impl Downloader {
|
||||
.iter()
|
||||
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
|
||||
.ok_or(format!(
|
||||
"No universal macOS DMG asset found for Brave version {}",
|
||||
version
|
||||
"No universal macOS DMG asset found for Brave version {version}"
|
||||
))?;
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
}
|
||||
BrowserType::Zen => {
|
||||
// For Zen, verify the asset exists
|
||||
let releases = self.api_client.fetch_zen_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_zen_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Zen version {} not found", version))?;
|
||||
.ok_or(format!("Zen version {version} not found"))?;
|
||||
|
||||
// Find the macOS universal DMG asset
|
||||
let asset = release
|
||||
@@ -81,20 +94,22 @@ impl Downloader {
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.macos-universal.dmg")
|
||||
.ok_or(format!(
|
||||
"No macOS universal asset found for Zen version {}",
|
||||
version
|
||||
"No macOS universal asset found for Zen version {version}"
|
||||
))?;
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
}
|
||||
BrowserType::MullvadBrowser => {
|
||||
// For Mullvad, verify the asset exists
|
||||
let releases = self.api_client.fetch_mullvad_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_mullvad_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Mullvad version {} not found", version))?;
|
||||
.ok_or(format!("Mullvad version {version} not found"))?;
|
||||
|
||||
// Find the macOS DMG asset
|
||||
let asset = release
|
||||
@@ -102,8 +117,7 @@ impl Downloader {
|
||||
.iter()
|
||||
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
|
||||
.ok_or(format!(
|
||||
"No macOS asset found for Mullvad version {}",
|
||||
version
|
||||
"No macOS asset found for Mullvad version {version}"
|
||||
))?;
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
@@ -115,9 +129,9 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_browser(
|
||||
pub async fn download_browser<R: tauri::Runtime>(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
browser_type: BrowserType,
|
||||
version: &str,
|
||||
download_info: &DownloadInfo,
|
||||
@@ -130,6 +144,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(),
|
||||
@@ -139,7 +157,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);
|
||||
@@ -152,6 +174,11 @@ impl Downloader {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let total_size = response.content_length();
|
||||
let mut downloaded = 0u64;
|
||||
let start_time = std::time::Instant::now();
|
||||
@@ -186,6 +213,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(),
|
||||
@@ -194,7 +227,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);
|
||||
@@ -209,12 +242,63 @@ impl Downloader {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_version_service::DownloadInfo;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Test with a known Brave version
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
@@ -225,26 +309,43 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/brave/brave-browser"));
|
||||
assert!(url.contains(".dmg"));
|
||||
assert!(url.contains("universal"));
|
||||
println!("Brave download URL resolved: {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Brave URL resolution failed (expected if version doesn't exist): {}",
|
||||
e
|
||||
);
|
||||
// This might fail if the version doesn't exist, which is okay for testing
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg",
|
||||
"size": 120000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
@@ -256,24 +357,43 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/zen-browser/desktop"));
|
||||
assert!(url.contains("zen.macos-universal.dmg"));
|
||||
println!("Zen download URL resolved: {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Zen URL resolution failed (expected if version doesn't exist): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/zen-1.11b-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
|
||||
"size": 100000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
@@ -285,24 +405,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/mullvad/mullvad-browser"));
|
||||
assert!(url.contains(".dmg"));
|
||||
println!("Mullvad download URL resolved: {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Mullvad URL resolution failed (expected if version doesn't exist): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/mullvad-14.5a6.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_firefox_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
|
||||
@@ -314,20 +426,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("Firefox download URL (passthrough): {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Firefox URL resolution should not fail: {}", e);
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_chromium_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
|
||||
@@ -339,20 +447,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("Chromium download URL (passthrough): {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Chromium URL resolution should not fail: {}", e);
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_tor_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://archive.torproject.org/tor-package-archive/torbrowser/14.0.4/tor-browser-macos-14.0.4.dmg".to_string(),
|
||||
@@ -364,14 +468,352 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::TorBrowser, "14.0.4", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("TOR download URL (passthrough): {}", url);
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_version_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("TOR URL resolution should not fail: {}", e);
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Brave version v1.81.9 not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.linux-universal.tar.bz2",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2",
|
||||
"size": 150000000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS universal asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_with_progress() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
// Create a temporary directory for the test
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create test file content (simulating a small download)
|
||||
let test_content = b"This is a test file content for download simulation";
|
||||
|
||||
// Mock the download endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content)
|
||||
.insert_header("content-length", test_content.len().to_string())
|
||||
.insert_header("content-type", "application/octet-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/test-download", server.uri()),
|
||||
filename: "test-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Create a mock app handle for testing
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let downloaded_file = result.unwrap();
|
||||
assert!(downloaded_file.exists());
|
||||
|
||||
// Verify file content
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_network_error() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// 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;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/missing-file", server.uri()),
|
||||
filename: "missing-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-linux-14.5a6.tar.xz",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz",
|
||||
"size": 80000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brave_version_with_v_prefix() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Test with version without v prefix
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_chunked_response() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create larger test content to simulate chunked transfer
|
||||
let test_content = vec![42u8; 1024]; // 1KB of data
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chunked-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content.clone())
|
||||
.insert_header("content-length", test_content.len().to_string())
|
||||
.insert_header("content-type", "application/octet-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/chunked-download", server.uri()),
|
||||
filename: "chunked-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Chromium,
|
||||
"1465660",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let downloaded_file = result.unwrap();
|
||||
assert!(downloaded_file.exists());
|
||||
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -127,13 +133,15 @@ impl DownloadedBrowsersRegistry {
|
||||
info.actual_version = actual_version;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Browser {}:{} not found in registry",
|
||||
browser, version
|
||||
))
|
||||
Err(format!("Browser {browser}:{version} not found in registry"))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -189,6 +197,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());
|
||||
@@ -209,6 +219,8 @@ mod tests {
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
@@ -218,6 +230,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 {
|
||||
@@ -227,6 +241,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);
|
||||
@@ -269,6 +285,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);
|
||||
@@ -278,4 +296,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 = ®istry.browsers["zen"];
|
||||
let twilight_info = &zen_versions["twilight"];
|
||||
assert!(twilight_info.is_rolling_release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ impl Extractor {
|
||||
match extension {
|
||||
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
|
||||
"zip" => self.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {}", extension).into()),
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_dmg(
|
||||
pub async fn extract_dmg(
|
||||
&self,
|
||||
dmg_path: &Path,
|
||||
dest_dir: &Path,
|
||||
@@ -149,7 +149,7 @@ impl Extractor {
|
||||
Ok(app_path)
|
||||
}
|
||||
|
||||
async fn extract_zip(
|
||||
pub async fn extract_zip(
|
||||
&self,
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
|
||||
+49
-13
@@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
mod api_client;
|
||||
mod app_auto_updater;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
@@ -52,16 +53,21 @@ use auto_updater::{
|
||||
mark_auto_update_download, remove_auto_update_download, start_browser_update,
|
||||
};
|
||||
|
||||
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)
|
||||
format!("Hello world from Rust! Current epoch: {epoch_ms}")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
println!("handle_url_open called with URL: {}", url);
|
||||
println!("handle_url_open called with URL: {url}");
|
||||
|
||||
// Check if the main window exists and is ready
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -70,7 +76,7 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
println!("Main window is visible, emitting show-profile-selector event");
|
||||
app
|
||||
.emit("show-profile-selector", url.clone())
|
||||
.map_err(|e| format!("Failed to emit URL open event: {}", e))?;
|
||||
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
@@ -105,10 +111,10 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
|
||||
);
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Emitting show-profile-selector event for URL: {}", url);
|
||||
println!("Emitting show-profile-selector event for URL: {url}");
|
||||
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
|
||||
eprintln!("Failed to emit URL event: {}", e);
|
||||
return Err(format!("Failed to emit URL event: {}", e));
|
||||
eprintln!("Failed to emit URL event: {e}");
|
||||
return Err(format!("Failed to emit URL event: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +150,7 @@ pub fn run() {
|
||||
let urls = event.urls();
|
||||
for url in urls {
|
||||
let url_string = url.to_string();
|
||||
println!("Deep link received: {}", url_string);
|
||||
println!("Deep link received: {url_string}");
|
||||
|
||||
// Clone the handle for each async task
|
||||
let handle_clone = handle.clone();
|
||||
@@ -152,7 +158,7 @@ pub fn run() {
|
||||
// Handle the URL asynchronously
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
|
||||
eprintln!("Failed to handle deep link URL: {}", e);
|
||||
eprintln!("Failed to handle deep link URL: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -172,6 +178,36 @@ pub fn run() {
|
||||
updater_guard.start_background_updates().await;
|
||||
});
|
||||
|
||||
// Check for app updates at startup
|
||||
let app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Add a small delay to ensure the app is fully loaded
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
println!("Starting app update check at startup...");
|
||||
let updater = app_auto_updater::AppAutoUpdater::new();
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
println!(
|
||||
"App update available: {} -> {}",
|
||||
update_info.current_version, update_info.new_version
|
||||
);
|
||||
// Emit update available event to the frontend
|
||||
if let Err(e) = app_handle_update.emit("app-update-available", &update_info) {
|
||||
eprintln!("Failed to emit app update event: {e}");
|
||||
} else {
|
||||
println!("App update event emitted successfully");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("No app updates available");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to check for app updates: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -182,7 +218,7 @@ pub fn run() {
|
||||
is_browser_downloaded,
|
||||
check_browser_exists,
|
||||
create_browser_profile_new,
|
||||
create_browser_profile, // Keep for backward compatibility
|
||||
create_browser_profile,
|
||||
list_browser_profiles,
|
||||
launch_browser_profile,
|
||||
fetch_browser_versions,
|
||||
@@ -199,26 +235,22 @@ pub fn run() {
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
// Settings commands
|
||||
get_app_settings,
|
||||
save_app_settings,
|
||||
should_show_settings_on_startup,
|
||||
disable_default_browser_prompt,
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
// Default browser commands
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
set_as_default_browser,
|
||||
smart_open_url,
|
||||
handle_url_open,
|
||||
check_and_handle_startup_url,
|
||||
// Version update commands
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_version_update_needed,
|
||||
force_version_update_check,
|
||||
// Auto-update commands
|
||||
check_for_browser_updates,
|
||||
start_browser_update,
|
||||
complete_browser_update,
|
||||
@@ -228,6 +260,10 @@ pub fn run() {
|
||||
mark_auto_update_download,
|
||||
remove_auto_update_download,
|
||||
is_auto_update_download,
|
||||
check_for_app_updates,
|
||||
check_for_app_updates_manual,
|
||||
download_and_install_app_update,
|
||||
get_app_version_info,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -78,15 +78,15 @@ impl ProxyManager {
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Proxy start failed: {}", stderr));
|
||||
return Err(format!("Proxy start failed: {stderr}"));
|
||||
}
|
||||
|
||||
let json_string = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("Failed to parse proxy output: {}", e))?;
|
||||
let json_string =
|
||||
String::from_utf8(output.stdout).map_err(|e| format!("Failed to parse proxy output: {e}"))?;
|
||||
|
||||
// Parse the JSON output
|
||||
let json: Value =
|
||||
serde_json::from_str(&json_string).map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
serde_json::from_str(&json_string).map_err(|e| format!("Failed to parse JSON: {e}"))?;
|
||||
|
||||
// Extract proxy information
|
||||
let id = json["id"].as_str().ok_or("Missing proxy ID")?;
|
||||
@@ -146,7 +146,7 @@ impl ProxyManager {
|
||||
let nodecar = app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create sidecar: {}", e))?
|
||||
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
||||
.arg("proxy")
|
||||
.arg("stop")
|
||||
.arg("--id")
|
||||
@@ -156,7 +156,7 @@ impl ProxyManager {
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
eprintln!("Proxy stop error: {}", stderr);
|
||||
eprintln!("Proxy stop error: {stderr}");
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
}
|
||||
|
||||
|
||||
@@ -98,23 +98,17 @@ impl SettingsManager {
|
||||
Ok(settings) => {
|
||||
// Save the settings back to ensure any missing fields are written with defaults
|
||||
if let Err(e) = self.save_settings(&settings) {
|
||||
eprintln!(
|
||||
"Warning: Failed to update settings file with defaults: {}",
|
||||
e
|
||||
);
|
||||
eprintln!("Warning: Failed to update settings file with defaults: {e}");
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: Failed to parse settings file, using defaults: {}",
|
||||
e
|
||||
);
|
||||
eprintln!("Warning: Failed to parse settings file, using defaults: {e}");
|
||||
let default_settings = AppSettings::default();
|
||||
|
||||
// Try to save default settings to fix the corrupted file
|
||||
if let Err(save_error) = self.save_settings(&default_settings) {
|
||||
eprintln!("Warning: Failed to save default settings: {}", save_error);
|
||||
eprintln!("Warning: Failed to save default settings: {save_error}");
|
||||
}
|
||||
|
||||
Ok(default_settings)
|
||||
@@ -183,7 +177,7 @@ pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -191,7 +185,7 @@ pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -199,7 +193,7 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.should_show_settings_on_startup()
|
||||
.map_err(|e| format!("Failed to check prompt setting: {}", e))
|
||||
.map_err(|e| format!("Failed to check prompt setting: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -207,7 +201,7 @@ 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))
|
||||
.map_err(|e| format!("Failed to disable prompt: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -215,7 +209,7 @@ pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.load_table_sorting()
|
||||
.map_err(|e| format!("Failed to load table sorting settings: {}", e))
|
||||
.map_err(|e| format!("Failed to load table sorting settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -223,5 +217,5 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.save_table_sorting(&sorting)
|
||||
.map_err(|e| format!("Failed to save table sorting settings: {}", e))
|
||||
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
|
||||
}
|
||||
|
||||
@@ -174,13 +174,13 @@ impl VersionUpdater {
|
||||
};
|
||||
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {}", e);
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Startup version update completed successfully");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Startup version update failed: {}", e);
|
||||
eprintln!("Startup version update failed: {e}");
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ impl VersionUpdater {
|
||||
|
||||
// Run initial startup check
|
||||
if let Err(e) = self.check_and_run_startup_update().await {
|
||||
eprintln!("Startup version update failed: {}", e);
|
||||
eprintln!("Startup version update failed: {e}");
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -227,13 +227,13 @@ impl VersionUpdater {
|
||||
};
|
||||
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {}", e);
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Background version update completed successfully");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Background version update failed: {}", e);
|
||||
eprintln!("Background version update failed: {e}");
|
||||
|
||||
// Emit error event
|
||||
let progress = VersionUpdateProgress {
|
||||
@@ -287,7 +287,7 @@ impl VersionUpdater {
|
||||
for (index, browser) in browsers.iter().enumerate() {
|
||||
// Check if individual browser cache is expired before updating
|
||||
if !self.version_service.should_update_cache(browser) {
|
||||
println!("Skipping {} - cache is still fresh", browser);
|
||||
println!("Skipping {browser} - cache is still fresh");
|
||||
|
||||
let browser_result = BackgroundUpdateResult {
|
||||
browser: browser.to_string(),
|
||||
@@ -300,7 +300,7 @@ impl VersionUpdater {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("Updating versions for browser: {}", browser);
|
||||
println!("Updating versions for browser: {browser}");
|
||||
|
||||
// Emit progress for current browser
|
||||
let progress = VersionUpdateProgress {
|
||||
@@ -327,10 +327,10 @@ impl VersionUpdater {
|
||||
};
|
||||
results.push(browser_result);
|
||||
|
||||
println!("Found {} new versions for {}", new_count, browser);
|
||||
println!("Found {new_count} new versions for {browser}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to update versions for {}: {}", browser, e);
|
||||
eprintln!("Failed to update versions for {browser}: {e}");
|
||||
let browser_result = BackgroundUpdateResult {
|
||||
browser: browser.to_string(),
|
||||
new_versions_count: 0,
|
||||
@@ -357,10 +357,7 @@ impl VersionUpdater {
|
||||
};
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
|
||||
println!(
|
||||
"Background version update completed. Found {} new versions total",
|
||||
total_new_versions
|
||||
);
|
||||
println!("Background version update completed. Found {total_new_versions} new versions total");
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
@@ -388,10 +385,7 @@ impl VersionUpdater {
|
||||
};
|
||||
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!(
|
||||
"Failed to save background update state after manual update: {}",
|
||||
e
|
||||
);
|
||||
eprintln!("Failed to save background update state after manual update: {e}");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
@@ -440,7 +434,7 @@ pub async fn trigger_manual_version_update(
|
||||
updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger manual update: {}", e))
|
||||
.map_err(|e| format!("Failed to trigger manual update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -466,7 +460,7 @@ pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result
|
||||
|
||||
match updater_guard.check_and_run_startup_update().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Failed to run version update check: {}", e)),
|
||||
Err(e) => Err(format!("Failed to run version update check: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +471,7 @@ mod tests {
|
||||
// Helper function to create a unique test state file
|
||||
fn get_test_state_file(test_name: &str) -> PathBuf {
|
||||
let cache_dir = VersionUpdater::get_cache_dir().unwrap();
|
||||
cache_dir.join(format!("test_{}_state.json", test_name))
|
||||
cache_dir.join(format!("test_{test_name}_state.json"))
|
||||
}
|
||||
|
||||
fn save_test_state(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.4",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+53
-108
@@ -13,6 +13,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, ProxySettings } from "@/types";
|
||||
@@ -47,20 +48,26 @@ export default function Home() {
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// Auto-update functionality - only initialize on client
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
const { checkForUpdates, isUpdating } = updateNotifications;
|
||||
|
||||
// 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",
|
||||
@@ -73,12 +80,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();
|
||||
@@ -100,11 +107,9 @@ export default function Home() {
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [loadProfiles, checkForUpdates, isClient]);
|
||||
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
|
||||
|
||||
const checkStartupPrompt = async () => {
|
||||
if (!isClient) return; // Only run on client side
|
||||
|
||||
try {
|
||||
const shouldShow = await invoke<boolean>(
|
||||
"should_show_settings_on_startup",
|
||||
@@ -118,8 +123,6 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const checkStartupUrls = async () => {
|
||||
if (!isClient) return; // Only run on client side
|
||||
|
||||
try {
|
||||
const hasStartupUrl = await invoke<boolean>(
|
||||
"check_and_handle_startup_url",
|
||||
@@ -133,8 +136,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) => {
|
||||
@@ -145,10 +146,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
|
||||
@@ -168,15 +166,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:",
|
||||
@@ -184,7 +180,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 }]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,7 +246,9 @@ export default function Home() {
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to create profile: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to create profile: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -263,40 +262,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
|
||||
@@ -330,11 +322,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) {
|
||||
@@ -345,7 +337,7 @@ export default function Home() {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus, isClient]);
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
@@ -401,53 +393,6 @@ 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">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
interface AppUpdateInfo {
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
release_notes: string;
|
||||
download_url: string;
|
||||
is_nightly: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface AppUpdateToastProps {
|
||||
updateInfo: AppUpdateInfo;
|
||||
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
isUpdating?: boolean;
|
||||
updateProgress?: string;
|
||||
}
|
||||
|
||||
export function AppUpdateToast({
|
||||
updateInfo,
|
||||
onUpdate,
|
||||
onDismiss,
|
||||
isUpdating = false,
|
||||
updateProgress,
|
||||
}: AppUpdateToastProps) {
|
||||
const handleUpdateClick = async () => {
|
||||
await onUpdate(updateInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-lg max-w-md">
|
||||
<div className="mr-3 mt-0.5">
|
||||
{isUpdating ? (
|
||||
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
|
||||
) : (
|
||||
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground text-sm">
|
||||
Donut Browser Update Available
|
||||
</span>
|
||||
<Badge
|
||||
variant={updateInfo.is_nightly ? "secondary" : "default"}
|
||||
className="text-xs"
|
||||
>
|
||||
{updateInfo.is_nightly ? "Nightly" : "Stable"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Update from {updateInfo.current_version} to{" "}
|
||||
<span className="font-medium">{updateInfo.new_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUpdating && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
>
|
||||
<FaTimes className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUpdating && updateProgress && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground">{updateProgress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUpdating && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button
|
||||
onClick={() => void handleUpdateClick()}
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<FaDownload className="h-3 w-3" />
|
||||
Update Now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDismiss}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo.release_notes && !isUpdating && (
|
||||
<div className="mt-2">
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Release Notes
|
||||
</summary>
|
||||
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
|
||||
{updateInfo.release_notes.length > 200
|
||||
? `${updateInfo.release_notes.substring(0, 200)}...`
|
||||
: updateInfo.release_notes}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -78,6 +78,11 @@ export function ProfilesDataTable({
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [newProfileName, setNewProfileName] = React.useState("");
|
||||
const [renameError, setRenameError] = React.useState<string | null>(null);
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [deleteConfirmationName, setDeleteConfirmationName] =
|
||||
React.useState("");
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
@@ -117,6 +122,26 @@ export function ProfilesDataTable({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!profileToDelete || !deleteConfirmationName.trim()) return;
|
||||
|
||||
if (deleteConfirmationName.trim() !== profileToDelete.name) {
|
||||
setDeleteError(
|
||||
"Profile name doesn't match. Please type the exact name to confirm deletion.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteProfile(profileToDelete);
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
} catch (err) {
|
||||
setDeleteError(err as string);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -376,14 +401,17 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Rename profile
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void onDeleteProfile(profile)}
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
setDeleteConfirmationName("");
|
||||
}}
|
||||
className="text-red-600"
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Delete profile
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -400,7 +428,6 @@ export function ProfilesDataTable({
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onProxySettings,
|
||||
onDeleteProfile,
|
||||
onChangeVersion,
|
||||
],
|
||||
);
|
||||
@@ -514,6 +541,69 @@ export function ProfilesDataTable({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={profileToDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
profile "{profileToDelete?.name}" and all its associated
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="delete-confirmation">
|
||||
Please type <strong>{profileToDelete?.name}</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirmation"
|
||||
value={deleteConfirmationName}
|
||||
onChange={(e) => {
|
||||
setDeleteConfirmationName(e.target.value);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
placeholder="Type the profile name here"
|
||||
/>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<p className="text-sm text-red-600">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={
|
||||
!deleteConfirmationName.trim() ||
|
||||
deleteConfirmationName !== profileToDelete?.name
|
||||
}
|
||||
>
|
||||
Delete Profile
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -27,6 +27,11 @@ function getSystemTheme(): string {
|
||||
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 () => {
|
||||
@@ -65,11 +70,18 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
const systemTheme = getSystemTheme();
|
||||
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
|
||||
spinnerColor =
|
||||
systemTheme === "dark" ? "border-white" : "border-gray-900";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import type { AppUpdateInfo } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useAppUpdateNotifications() {
|
||||
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] = useState<string>("");
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const checkForAppUpdates = useCallback(async () => {
|
||||
if (!isClient) return;
|
||||
|
||||
try {
|
||||
const update = await invoke<AppUpdateInfo | null>(
|
||||
"check_for_app_updates",
|
||||
);
|
||||
|
||||
// Don't show update if this version was already dismissed
|
||||
if (update && update.new_version !== dismissedVersion) {
|
||||
setUpdateInfo(update);
|
||||
} else if (update) {
|
||||
console.log("Update available but dismissed:", update.new_version);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for app updates:", error);
|
||||
}
|
||||
}, [isClient, dismissedVersion]);
|
||||
|
||||
const checkForAppUpdatesManual = useCallback(async () => {
|
||||
if (!isClient) return;
|
||||
|
||||
try {
|
||||
console.log("Triggering manual app update check...");
|
||||
const update = await invoke<AppUpdateInfo | null>(
|
||||
"check_for_app_updates_manual",
|
||||
);
|
||||
console.log("Manual check result:", update);
|
||||
|
||||
// Always show manual check results, even if previously dismissed
|
||||
setUpdateInfo(update);
|
||||
} catch (error) {
|
||||
console.error("Failed to manually check for app updates:", error);
|
||||
}
|
||||
}, [isClient]);
|
||||
|
||||
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
setUpdateProgress("Starting update...");
|
||||
|
||||
await invoke("download_and_install_app_update", {
|
||||
updateInfo: appUpdateInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update app:", error);
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Failed to update Donut Browser",
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissAppUpdate = useCallback(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Remember the dismissed version so we don't show it again
|
||||
if (updateInfo) {
|
||||
setDismissedVersion(updateInfo.new_version);
|
||||
console.log("Dismissed app update version:", updateInfo.new_version);
|
||||
}
|
||||
|
||||
setUpdateInfo(null);
|
||||
toast.dismiss("app-update");
|
||||
}, [isClient, updateInfo]);
|
||||
|
||||
// Listen for app update availability
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
const unlistenUpdate = listen<AppUpdateInfo>(
|
||||
"app-update-available",
|
||||
(event) => {
|
||||
console.log("App update available:", event.payload);
|
||||
setUpdateInfo(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenProgress = listen<string>("app-update-progress", (event) => {
|
||||
console.log("App update progress:", event.payload);
|
||||
setUpdateProgress(event.payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenUpdate.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
void unlistenProgress.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
};
|
||||
}, [isClient]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
if (!isClient || !updateInfo) return;
|
||||
|
||||
toast.custom(
|
||||
() => (
|
||||
<AppUpdateToast
|
||||
updateInfo={updateInfo}
|
||||
onUpdate={handleAppUpdate}
|
||||
onDismiss={dismissAppUpdate}
|
||||
isUpdating={isUpdating}
|
||||
updateProgress={updateProgress}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id: "app-update",
|
||||
duration: Number.POSITIVE_INFINITY, // Persistent until user action
|
||||
position: "top-left",
|
||||
},
|
||||
);
|
||||
}, [
|
||||
updateInfo,
|
||||
handleAppUpdate,
|
||||
dismissAppUpdate,
|
||||
isUpdating,
|
||||
updateProgress,
|
||||
isClient,
|
||||
]);
|
||||
|
||||
// Check for app updates on startup
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Check for updates immediately on startup
|
||||
void checkForAppUpdates();
|
||||
}, [isClient, checkForAppUpdates]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
isUpdating,
|
||||
checkForAppUpdates,
|
||||
checkForAppUpdatesManual,
|
||||
dismissAppUpdate,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -201,7 +237,7 @@ export function useUpdateNotifications() {
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]);
|
||||
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
|
||||
+45
-4
@@ -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;
|
||||
@@ -149,7 +164,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 +180,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 +267,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);
|
||||
|
||||
@@ -19,3 +19,17 @@ export interface BrowserProfile {
|
||||
process_id?: number;
|
||||
last_launch?: number;
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
release_notes: string;
|
||||
download_url: string;
|
||||
is_nightly: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
version: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user