mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb87641890 | |||
| 3df5ac671b | |||
| 390f79f97b | |||
| c4dc2ed50c | |||
| 3b7315cc0d | |||
| bbd0f5df0c | |||
| 8e7982bdf8 | |||
| 9ac662aee8 | |||
| 2394716ea3 | |||
| 06992f8b9a | |||
| 5a454e3647 | |||
| 3f91d92d8b | |||
| c586046542 | |||
| 42f63172fb | |||
| f717600fcb | |||
| c807ea5596 | |||
| df2c1316d4 | |||
| 1fdc552dc7 | |||
| ab563f81fa | |||
| d17545bd05 | |||
| 29c329b432 | |||
| 4d7bbe719f | |||
| 5b869a6115 | |||
| c4b1745a0f | |||
| ac293f6204 | |||
| 7f3a3287d6 | |||
| dd9347d429 | |||
| 615d7b8c8a | |||
| 5c26ab5c33 | |||
| dccaf6c7de | |||
| bf5b2886f5 | |||
| bbc12bcc03 | |||
| 6d437f30e1 | |||
| 4b0ab6b732 | |||
| a802895491 | |||
| a57f90899b | |||
| 3ebc714b23 | |||
| 1acd4781b5 | |||
| a5b9afafcb | |||
| 0c8dd5ace5 | |||
| e8c3188657 | |||
| e6f0b2b9e9 | |||
| 394406e134 | |||
| b0ca14c184 | |||
| eea94ad360 | |||
| 2b678ed04d | |||
| dff201ddec | |||
| 743ad59348 | |||
| d43e9ef21b | |||
| 7515cbacd6 | |||
| f41172e822 | |||
| 25ce691bbc | |||
| b945ee7088 | |||
| eb3589b4c0 | |||
| b71b9a00ca | |||
| 6535b37c98 | |||
| bc72a837e2 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Don't leave comments that don't add value
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Generate changelog
|
||||
on:
|
||||
release:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
name: Generate changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate a changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
id: git-cliff
|
||||
with:
|
||||
args: --verbose
|
||||
env:
|
||||
OUTPUT: CHANGELOG.md
|
||||
|
||||
- name: Print the changelog
|
||||
run: cat "${{ steps.git-cliff.outputs.changelog }}"
|
||||
@@ -69,14 +69,13 @@ jobs:
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
# Future platforms can be added here:
|
||||
# - platform: "windows-latest"
|
||||
# args: "--target x86_64-pc-windows-msvc"
|
||||
# arch: "x86_64"
|
||||
# target: "x86_64-pc-windows-msvc"
|
||||
# pkg_target: "latest-win-x64"
|
||||
# nodecar_script: "build:win-x64"
|
||||
# - platform: "windows-latest"
|
||||
# - platform: "windows-11-arm"
|
||||
# args: "--target aarch64-pc-windows-msvc"
|
||||
# arch: "aarch64"
|
||||
# target: "aarch64-pc-windows-msvc"
|
||||
|
||||
@@ -68,6 +68,18 @@ jobs:
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
- platform: "windows-latest"
|
||||
args: "--target x86_64-pc-windows-msvc"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-pc-windows-msvc"
|
||||
pkg_target: "latest-win-x64"
|
||||
nodecar_script: "build:win-x64"
|
||||
- platform: "windows-11-arm"
|
||||
args: "--target aarch64-pc-windows-msvc"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-pc-windows-msvc"
|
||||
pkg_target: "latest-win-arm64"
|
||||
nodecar_script: "build:win-arm64"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
@@ -124,21 +136,26 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Get commit hash
|
||||
id: commit
|
||||
run: echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Generate nightly timestamp
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
tagName: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
Vendored
+8
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"ahooks",
|
||||
"appimage",
|
||||
"appindicator",
|
||||
"applescript",
|
||||
@@ -10,11 +11,14 @@
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"devedition",
|
||||
"doesn",
|
||||
"donutbrowser",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"esbuild",
|
||||
"eslintcache",
|
||||
@@ -40,6 +44,7 @@
|
||||
"nodecar",
|
||||
"ntlm",
|
||||
"objc",
|
||||
"orhun",
|
||||
"osascript",
|
||||
"pixbuf",
|
||||
"plasmohq",
|
||||
@@ -64,8 +69,11 @@
|
||||
"Torbrowser",
|
||||
"turbopack",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
"unrs",
|
||||
"urlencoding",
|
||||
"vercel",
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"xattr",
|
||||
"zhom"
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
# Instructions for AI Agents
|
||||
|
||||
- If you want to run tests, only ever run them as "pnpm format && pnpm lint && pnpm test".
|
||||
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
|
||||
- Don't leave comments that don't add value
|
||||
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
|
||||
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
|
||||
+1
-1
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
|
||||
|
||||
## Enforcement
|
||||
|
||||
Violations of the Code of Conduct may be reported by pinging @zhom on Github. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
|
||||
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
|
||||
</a>
|
||||
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
|
||||
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
|
||||
</a>
|
||||
@@ -30,6 +36,7 @@
|
||||
- Create unlimited number of local browser profiles completely isolated from each other
|
||||
- Proxy support with basic auth for all browsers except for TOR Browser
|
||||
- Import profiles from your existing browsers
|
||||
- Automatic updates both for browsers and for the app itself
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
|
||||
## Download
|
||||
@@ -49,8 +56,6 @@ The app can be downloaded from the [releases page](https://github.com/zhom/donut
|
||||
|
||||
### Contributing
|
||||
|
||||
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Issues
|
||||
|
||||
+1
-42
@@ -68,48 +68,7 @@ const eslintConfig = tseslint.config(
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
|
||||
"@typescript-eslint/adjacent-overload-signatures": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/consistent-type-exports": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
"@typescript-eslint/default-param-last": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-dupe-class-members": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-extra-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/no-loss-of-precision": "off",
|
||||
"@typescript-eslint/no-misused-new": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-redeclare": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-restricted-imports": "off",
|
||||
"@typescript-eslint/no-restricted-types": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-empty-export": "off",
|
||||
"@typescript-eslint/only-throw-error": "off",
|
||||
"@typescript-eslint/parameter-properties": "off",
|
||||
"@typescript-eslint/prefer-as-const": "off",
|
||||
"@typescript-eslint/prefer-enum-initializers": "off",
|
||||
"@typescript-eslint/prefer-for-of": "off",
|
||||
"@typescript-eslint/prefer-function-type": "off",
|
||||
"@typescript-eslint/prefer-literal-enum-member": "off",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "off",
|
||||
"@typescript-eslint/prefer-optional-chain": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
// Custom rules
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
@@ -127,7 +86,7 @@ const eslintConfig = tseslint.config(
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -21,5 +21,8 @@ if [ -z "$TARGET_TRIPLE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the file
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
# Copy the file with target triple suffix
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
|
||||
# Also copy a generic version for Tauri to find
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
|
||||
@@ -66,48 +66,7 @@ const eslintConfig = tseslint.config(
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
|
||||
"@typescript-eslint/adjacent-overload-signatures": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/consistent-type-exports": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
"@typescript-eslint/default-param-last": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-dupe-class-members": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-extra-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/no-loss-of-precision": "off",
|
||||
"@typescript-eslint/no-misused-new": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-redeclare": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-restricted-imports": "off",
|
||||
"@typescript-eslint/no-restricted-types": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-empty-export": "off",
|
||||
"@typescript-eslint/only-throw-error": "off",
|
||||
"@typescript-eslint/parameter-properties": "off",
|
||||
"@typescript-eslint/prefer-as-const": "off",
|
||||
"@typescript-eslint/prefer-enum-initializers": "off",
|
||||
"@typescript-eslint/prefer-for-of": "off",
|
||||
"@typescript-eslint/prefer-function-type": "off",
|
||||
"@typescript-eslint/prefer-literal-enum-member": "off",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "off",
|
||||
"@typescript-eslint/prefer-optional-chain": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
// Custom rules
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
"name": "nodecar",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
|
||||
"dev": "node --loader ts-node/esm ./src/index.ts",
|
||||
"start": "node --loader ts-node/esm ./src/index.ts",
|
||||
"start": "tsc && node ./dist/index.js",
|
||||
"test": "tsc && node ./dist/test-proxy.js",
|
||||
"rename-binary": "sh ./copy-binary.sh",
|
||||
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
@@ -20,7 +21,7 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/node": "^24.0.1",
|
||||
"@yao-pkg/pkg": "^6.5.1",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^16.5.0",
|
||||
@@ -28,6 +29,7 @@
|
||||
"nodemon": "^3.1.10",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
}
|
||||
}
|
||||
|
||||
+71
-23
@@ -1,8 +1,8 @@
|
||||
import { program } from "commander";
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopProxyProcess,
|
||||
stopAllProxyProcesses,
|
||||
stopProxyProcess,
|
||||
} from "./proxy-runner";
|
||||
import { listProxyConfigs } from "./proxy-storage";
|
||||
import { runProxyWorker } from "./proxy-worker";
|
||||
@@ -11,79 +11,127 @@ import { runProxyWorker } from "./proxy-worker";
|
||||
program
|
||||
.command("proxy")
|
||||
.argument("<action>", "start, stop, or list proxies")
|
||||
.option(
|
||||
"-u, --upstream <url>",
|
||||
"upstream proxy URL (protocol://[username:password@]host:port)"
|
||||
)
|
||||
.option("--host <host>", "upstream proxy host")
|
||||
.option("--proxy-port <port>", "upstream proxy port", Number.parseInt)
|
||||
.option("--type <type>", "proxy type (http, https, socks4, socks5)")
|
||||
.option("--username <username>", "proxy username")
|
||||
.option("--password <password>", "proxy password")
|
||||
.option(
|
||||
"-p, --port <number>",
|
||||
"local port to use (random if not specified)",
|
||||
Number.parseInt
|
||||
Number.parseInt,
|
||||
)
|
||||
.option("--ignore-certificate", "ignore certificate errors for HTTPS proxies")
|
||||
.option("--id <id>", "proxy ID for stop command")
|
||||
.option(
|
||||
"-u, --upstream <url>",
|
||||
"upstream proxy URL (protocol://[username:password@]host:port)",
|
||||
)
|
||||
.description("manage proxy servers")
|
||||
.action(
|
||||
async (
|
||||
action: string,
|
||||
options: {
|
||||
upstream?: string;
|
||||
host?: string;
|
||||
proxyPort?: number;
|
||||
type?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
ignoreCertificate?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
upstream?: string;
|
||||
},
|
||||
) => {
|
||||
if (action === "start") {
|
||||
if (!options.upstream) {
|
||||
console.error("Error: Upstream proxy URL is required");
|
||||
console.log(
|
||||
"Example: proxy start -u http://username:password@proxy.example.com:8080"
|
||||
let upstreamUrl: string;
|
||||
|
||||
// Build upstream URL from individual components if provided
|
||||
if (options.host && options.proxyPort && options.type) {
|
||||
const protocol =
|
||||
options.type === "socks4" || options.type === "socks5"
|
||||
? options.type
|
||||
: "http";
|
||||
const auth =
|
||||
options.username && options.password
|
||||
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
|
||||
options.password,
|
||||
)}@`
|
||||
: "";
|
||||
upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`;
|
||||
} else if (options.upstream) {
|
||||
upstreamUrl = options.upstream;
|
||||
} else {
|
||||
console.error(
|
||||
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
|
||||
);
|
||||
console.log(
|
||||
"Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await startProxyProcess(options.upstream, {
|
||||
const config = await startProxyProcess(upstreamUrl, {
|
||||
port: options.port,
|
||||
ignoreProxyCertificate: options.ignoreCertificate,
|
||||
});
|
||||
console.log(JSON.stringify(config));
|
||||
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
localPort: config.localPort,
|
||||
localUrl: config.localUrl,
|
||||
upstreamUrl: config.upstreamUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
// Exit successfully to allow the process to detach
|
||||
process.exit(0);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to start proxy: ${JSON.stringify(error)}`);
|
||||
console.error(
|
||||
`Failed to start proxy: ${
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (action === "stop") {
|
||||
if (options.id) {
|
||||
const stopped = await stopProxyProcess(options.id);
|
||||
console.log(`{
|
||||
"success": ${stopped}}`);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
} else if (options.upstream) {
|
||||
// Find proxies with this upstream URL
|
||||
const configs = listProxyConfigs().filter(
|
||||
(config) => config.upstreamUrl === options.upstream
|
||||
(config) => config.upstreamUrl === options.upstream,
|
||||
);
|
||||
|
||||
if (configs.length === 0) {
|
||||
console.error(`No proxies found for ${options.upstream}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
const stopped = await stopProxyProcess(config.id);
|
||||
console.log(`{
|
||||
"success": ${stopped}}`);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
}
|
||||
} else {
|
||||
await stopAllProxyProcesses();
|
||||
console.log(`{
|
||||
"success": true}`);
|
||||
console.log(JSON.stringify({ success: true }));
|
||||
}
|
||||
process.exit(0);
|
||||
} else if (action === "list") {
|
||||
const configs = listProxyConfigs();
|
||||
console.log(JSON.stringify(configs));
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("Invalid action. Use 'start', 'stop', or 'list'");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Command for proxy worker (internal use)
|
||||
|
||||
+38
-26
@@ -3,12 +3,12 @@ import path from "node:path";
|
||||
import getPort from "get-port";
|
||||
import {
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
getProxyConfig,
|
||||
deleteProxyConfig,
|
||||
isProcessRunning,
|
||||
generateProxyId,
|
||||
getProxyConfig,
|
||||
isProcessRunning,
|
||||
listProxyConfigs,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-storage";
|
||||
|
||||
/**
|
||||
@@ -19,50 +19,53 @@ import {
|
||||
*/
|
||||
export async function startProxyProcess(
|
||||
upstreamUrl: string,
|
||||
options: { port?: number; ignoreProxyCertificate?: boolean } = {}
|
||||
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
|
||||
): Promise<ProxyConfig> {
|
||||
// Generate a unique ID for this proxy
|
||||
const id = generateProxyId();
|
||||
|
||||
// Get a random available port if not specified
|
||||
const port = options.port || (await getPort());
|
||||
const port = options.port ?? (await getPort());
|
||||
|
||||
// Create the proxy configuration
|
||||
const config: ProxyConfig = {
|
||||
id,
|
||||
upstreamUrl,
|
||||
localPort: port,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate || false,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
|
||||
};
|
||||
|
||||
// Save the configuration before starting the process
|
||||
saveProxyConfig(config);
|
||||
|
||||
// Build the command arguments
|
||||
const args = ["proxy-worker", "start", "--id", id];
|
||||
const args = [
|
||||
path.join(__dirname, "index.js"),
|
||||
"proxy-worker",
|
||||
"start",
|
||||
"--id",
|
||||
id,
|
||||
];
|
||||
|
||||
// Spawn the process
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.join(__dirname, "index.js"), ...args],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}
|
||||
);
|
||||
// Spawn the process with proper detachment
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
// Unref the child to allow the parent to exit independently
|
||||
child.unref();
|
||||
|
||||
// Store the process ID
|
||||
// Store the process ID and local URL
|
||||
config.pid = child.pid;
|
||||
config.localUrl = `http://localhost:${port}`;
|
||||
config.localUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
// Update the configuration with the process ID
|
||||
saveProxyConfig(config);
|
||||
|
||||
// Wait a bit to ensure the proxy has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Give the worker a moment to start before returning
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -75,7 +78,9 @@ export async function startProxyProcess(
|
||||
export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
const config = getProxyConfig(id);
|
||||
|
||||
if (!config || !config.pid) {
|
||||
if (!config?.pid) {
|
||||
// Try to delete the config anyway in case it exists without a PID
|
||||
deleteProxyConfig(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -83,10 +88,16 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
// Check if the process is running
|
||||
if (isProcessRunning(config.pid)) {
|
||||
// Send SIGTERM to the process
|
||||
process.kill(config.pid);
|
||||
process.kill(config.pid, "SIGTERM");
|
||||
|
||||
// Wait a bit to ensure the process has terminated
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// If still running, send SIGKILL
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGKILL");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the configuration
|
||||
@@ -95,6 +106,8 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error stopping proxy ${id}:`, error);
|
||||
// Delete the configuration even if stopping failed
|
||||
deleteProxyConfig(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -106,7 +119,6 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
export async function stopAllProxyProcesses(): Promise<void> {
|
||||
const configs = listProxyConfigs();
|
||||
|
||||
for (const config of configs) {
|
||||
await stopProxyProcess(config.id);
|
||||
}
|
||||
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
|
||||
+41
-17
@@ -1,5 +1,5 @@
|
||||
import { Server } from "proxy-chain";
|
||||
import { getProxyConfig } from "./proxy-storage";
|
||||
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
|
||||
|
||||
/**
|
||||
* Run a proxy server as a worker process
|
||||
@@ -8,44 +8,68 @@ import { getProxyConfig } from "./proxy-storage";
|
||||
export async function runProxyWorker(id: string): Promise<void> {
|
||||
// Get the proxy configuration
|
||||
const config = getProxyConfig(id);
|
||||
|
||||
|
||||
if (!config) {
|
||||
console.error(`Proxy configuration ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
// Create a new proxy server
|
||||
const server = new Server({
|
||||
port: config.localPort,
|
||||
host: "localhost",
|
||||
host: "127.0.0.1",
|
||||
prepareRequestFunction: () => {
|
||||
return {
|
||||
upstreamProxyUrl: config.upstreamUrl,
|
||||
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate || false,
|
||||
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log(`Proxy worker ${id} received SIGTERM, shutting down...`);
|
||||
await server.close(true);
|
||||
|
||||
// Handle process termination gracefully
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
|
||||
try {
|
||||
await server.close(true);
|
||||
console.log(`Proxy worker ${id} shut down successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Error during shutdown for proxy ${id}:`, error);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(`Uncaught exception in proxy worker ${id}:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log(`Proxy worker ${id} received SIGINT, shutting down...`);
|
||||
await server.close(true);
|
||||
process.exit(0);
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
// Start the server
|
||||
try {
|
||||
await server.listen();
|
||||
|
||||
// Update the config with the actual port (in case it was auto-assigned)
|
||||
config.localPort = server.port;
|
||||
config.localUrl = `http://127.0.0.1:${server.port}`;
|
||||
updateProxyConfig(config);
|
||||
|
||||
console.log(`Proxy worker ${id} started on port ${server.port}`);
|
||||
console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`);
|
||||
|
||||
// Keep the process alive
|
||||
setInterval(() => {
|
||||
// Do nothing, just keep the process alive
|
||||
}, 60000);
|
||||
} catch (error) {
|
||||
console.error(`Failed to start proxy worker ${id}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopProxyProcess,
|
||||
stopAllProxyProcesses
|
||||
} from "./proxy-runner";
|
||||
import { listProxyConfigs } from "./proxy-storage";
|
||||
|
||||
// Type definitions
|
||||
interface ProxyOptions {
|
||||
port?: number;
|
||||
ignoreProxyCertificate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local proxy server that forwards to an upstream proxy
|
||||
* @param upstreamProxyUrl The upstream proxy URL (protocol://[username:password@]host:port)
|
||||
* @param options Optional configuration
|
||||
* @returns Promise resolving to the local proxy URL
|
||||
*/
|
||||
export async function startProxy(
|
||||
upstreamProxyUrl: string,
|
||||
options: ProxyOptions = {}
|
||||
): Promise<string> {
|
||||
const config = await startProxyProcess(upstreamProxyUrl, {
|
||||
port: options.port,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate,
|
||||
});
|
||||
|
||||
return config.localUrl || `http://localhost:${config.localPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific proxy by its upstream URL
|
||||
* @param upstreamProxyUrl The upstream proxy URL to stop
|
||||
* @returns Promise resolving to true if proxy was found and stopped, false otherwise
|
||||
*/
|
||||
export async function stopProxy(upstreamProxyUrl: string): Promise<boolean> {
|
||||
// Find all proxies with this upstream URL
|
||||
const configs = listProxyConfigs().filter(
|
||||
config => config.upstreamUrl === upstreamProxyUrl
|
||||
);
|
||||
|
||||
if (configs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop all matching proxies
|
||||
let success = true;
|
||||
for (const config of configs) {
|
||||
const stopped = await stopProxyProcess(config.id);
|
||||
if (!stopped) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all active proxy upstream URLs
|
||||
* @returns Array of upstream proxy URLs
|
||||
*/
|
||||
export function getActiveProxies(): string[] {
|
||||
return listProxyConfigs().map(config => config.upstreamUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active proxies
|
||||
* @returns Promise that resolves when all proxies are stopped
|
||||
*/
|
||||
export async function stopAllProxies(): Promise<void> {
|
||||
await stopAllProxyProcesses();
|
||||
}
|
||||
+17
-14
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -35,9 +35,11 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.3.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
||||
"@tauri-apps/plugin-fs": "~2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
||||
"ahooks": "^3.8.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -47,34 +49,35 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@next/eslint-plugin-next": "^15.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"eslint": "^9.28.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
||||
"@typescript-eslint/parser": "^8.34.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"lint-staged": "^16.1.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.33.1"
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"biome check --fix",
|
||||
"eslint --cache --fix"
|
||||
],
|
||||
|
||||
Generated
+847
-719
File diff suppressed because it is too large
Load Diff
Generated
+149
-62
@@ -13,9 +13,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
@@ -405,15 +405,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.18.0"
|
||||
version = "3.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1b094a32014c3d1f3944e4808e0e7c70e97dae0660886a8eb6dbc52d745badc"
|
||||
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
|
||||
checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
@@ -518,9 +518,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.25"
|
||||
version = "1.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951"
|
||||
checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
@@ -556,9 +556,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
@@ -993,13 +993,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.3.1"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"core-foundation 0.10.1",
|
||||
"directories",
|
||||
"futures-util",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"lazy_static",
|
||||
"objc2 0.6.1",
|
||||
"objc2-app-kit",
|
||||
@@ -1012,11 +1015,18 @@ dependencies = [
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-macos-permissions",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"urlencoding",
|
||||
"windows",
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
@@ -1094,9 +1104,9 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.11"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
@@ -1104,9 +1114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.11"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1187,9 +1197,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
||||
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
@@ -1724,9 +1734,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
@@ -1791,6 +1801,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1826,9 +1842,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
@@ -2259,9 +2275,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
version = "0.2.173"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -2284,9 +2300,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "liblzma-sys"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5839bad90c3cc2e0b8c4ed8296b80e86040240f81d46b9c0e9bc8dd51ddd3af1"
|
||||
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2305,9 +2321,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a"
|
||||
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
@@ -2346,6 +2362,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macos-accessibility-client"
|
||||
version = "0.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb"
|
||||
dependencies = [
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.11.0"
|
||||
@@ -2368,9 +2394,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -2388,10 +2414,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.8"
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
@@ -3128,9 +3164,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
|
||||
checksum = "3d77244ce2d584cd84f6a15f86195b8c9b2a0dfbfd817c09e0464244091a58ed"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.9.0",
|
||||
@@ -3266,9 +3302,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.32.0"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3406,9 +3442,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.12"
|
||||
version = "0.5.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
|
||||
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
@@ -3455,9 +3491,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.19"
|
||||
version = "0.12.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
|
||||
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3472,12 +3508,10 @@ dependencies = [
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
@@ -3550,9 +3584,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
@@ -3791,9 +3825,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.8"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -3948,9 +3982,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.0"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -4407,6 +4441,21 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-macos-permissions"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5607e0707d37d7b20e287cf0ce396d1efebe7b833b8e9cbd2ea4257091d9c604"
|
||||
dependencies = [
|
||||
"macos-accessibility-client",
|
||||
"objc2 0.6.1",
|
||||
"objc2-foundation 0.3.1",
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.2.7"
|
||||
@@ -4450,6 +4499,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97d0e07b40fb2eb13778e30778f5979347a2bf30e1b9d47f78ff7fe92d2e4b3d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.6.0"
|
||||
@@ -4769,9 +4834,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.9"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -4814,9 +4879,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -4841,14 +4906,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4876,9 +4951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
||||
checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4887,9 +4962,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
@@ -4992,6 +5067,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
@@ -5022,6 +5103,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -5358,9 +5445,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.1"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
@@ -5426,9 +5513,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
@@ -5974,9 +6061,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8"
|
||||
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.3.1"
|
||||
version = "0.4.1"
|
||||
description = "Simple Yet Powerful Browser Orchestrator"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -27,6 +27,7 @@ tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
directories = "6"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -36,16 +37,39 @@ base64 = "0.22"
|
||||
zip = "4"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
urlencoding = "2.1"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation="0.10"
|
||||
objc2 = "0.6.1"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
tokio-test = "0.4.4"
|
||||
wiremock = "0.6"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
+36
-26
@@ -2,47 +2,57 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Donut Browser</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Donut Browser</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>donutbrowser</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.1</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Web Browser</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>CFBundleURLIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025 Donut Browser</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>HTML document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Default</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.html</string>
|
||||
<string>public.xhtml</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Web site URL</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +1,3 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
const proxyString = "{{proxy_url}}";
|
||||
|
||||
// Split the proxy string to get the credentials part
|
||||
const parts = proxyString.split(" ")[1].split("@");
|
||||
if (parts.length > 1) {
|
||||
const credentials = parts[0];
|
||||
const encodedCredentials = encodeURIComponent(credentials);
|
||||
// Replace the original credentials with encoded ones
|
||||
return proxyString.replace(credentials, encodedCredentials);
|
||||
}
|
||||
|
||||
return proxyString;
|
||||
return "{{proxy_url}}";
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ fn main() {
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
|
||||
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
|
||||
// For nightly builds, use commit hash
|
||||
// For nightly builds, use timestamp format or fallback to commit hash
|
||||
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
|
||||
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,16 @@
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"deep-link:default",
|
||||
"deep-link:allow-register",
|
||||
"deep-link:allow-unregister",
|
||||
"deep-link:allow-is-registered",
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
"macos-permissions:allow-check-microphone-permission",
|
||||
"macos-permissions:allow-check-camera-permission"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,21 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-output</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -246,8 +246,7 @@ pub fn is_browser_version_nightly(
|
||||
if let Some(name) = release_name {
|
||||
!name.starts_with("Release")
|
||||
} else {
|
||||
// Fallback to version string analysis if no release name
|
||||
is_nightly_version(version)
|
||||
true
|
||||
}
|
||||
}
|
||||
"firefox" | "firefox-developer" => {
|
||||
@@ -806,22 +805,17 @@ impl ApiClient {
|
||||
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
|
||||
// For Linux, be strict about architecture matching - only allow assets that explicitly match the current architecture
|
||||
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
|
||||
|
||||
assets.iter().any(|asset| {
|
||||
if assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.ends_with(".zip")
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.ends_with(".deb") || name.ends_with(".rpm")
|
||||
})
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -398,6 +398,30 @@ impl AppAutoUpdater {
|
||||
Err("DMG extraction is only supported on macOS".into())
|
||||
}
|
||||
}
|
||||
"msi" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// For MSI files on Windows, we need to run the installer
|
||||
// MSI files can't be extracted like archives, they need to be executed
|
||||
// Return the path to the MSI file itself for installation
|
||||
Ok(archive_path.to_path_buf())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Err("MSI installation is only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
"exe" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// For exe installers on Windows, return the path for execution
|
||||
Ok(archive_path.to_path_buf())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Err("EXE installation is only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
@@ -406,71 +430,282 @@ impl AppAutoUpdater {
|
||||
/// Install the update by replacing the current app
|
||||
async fn install_update(
|
||||
&self,
|
||||
new_app_path: &Path,
|
||||
installer_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()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 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)?;
|
||||
// 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(installer_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(())
|
||||
}
|
||||
|
||||
// Move current app to backup
|
||||
fs::rename(¤t_app_path, &backup_path)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let extension = installer_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Move new app to current location
|
||||
fs::rename(new_app_path, ¤t_app_path)?;
|
||||
println!("Installing Windows update with extension: {extension}");
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
match extension {
|
||||
"msi" => {
|
||||
// Install MSI silently with enhanced error handling
|
||||
println!("Running MSI installer: {}", installer_path.display());
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
let mut cmd = Command::new("msiexec");
|
||||
cmd.args([
|
||||
"/i",
|
||||
installer_path.to_str().unwrap(),
|
||||
"/quiet",
|
||||
"/norestart",
|
||||
"REBOOT=ReallySuppress",
|
||||
"/l*v", // Enable verbose logging
|
||||
&format!("{}.log", installer_path.to_str().unwrap()),
|
||||
]);
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
let output = cmd.output()?;
|
||||
|
||||
Ok(())
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
|
||||
// Try to read the log file for more details
|
||||
let log_path = format!("{}.log", installer_path.to_str().unwrap());
|
||||
let log_content = fs::read_to_string(&log_path).unwrap_or_default();
|
||||
|
||||
println!("MSI installation failed with exit code: {exit_code}");
|
||||
println!("Error output: {error_msg}");
|
||||
if !log_content.is_empty() {
|
||||
println!(
|
||||
"Log file content (last 500 chars): {}",
|
||||
&log_content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(500)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
return Err(
|
||||
format!("MSI installation failed (exit code {exit_code}): {error_msg}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
println!("MSI installation completed successfully");
|
||||
}
|
||||
"exe" => {
|
||||
// Run exe installer silently with multiple fallback options
|
||||
println!("Running EXE installer: {}", installer_path.display());
|
||||
|
||||
// Try NSIS silent flag first (most common for Tauri)
|
||||
let mut success = false;
|
||||
let mut last_error = String::new();
|
||||
|
||||
// NSIS installer flags (used by Tauri)
|
||||
let nsis_args = vec![
|
||||
vec!["/S"], // Standard NSIS silent flag
|
||||
vec!["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], // Inno Setup flags
|
||||
vec!["/quiet"], // Generic quiet flag
|
||||
vec!["/silent"], // Alternative silent flag
|
||||
];
|
||||
|
||||
for args in nsis_args {
|
||||
println!("Trying installer with args: {:?}", args);
|
||||
let output = Command::new(installer_path).args(&args).output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
println!(
|
||||
"EXE installation completed successfully with args: {:?}",
|
||||
args
|
||||
);
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(output) => {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
last_error = format!(
|
||||
"Exit code {}: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
error_msg
|
||||
);
|
||||
println!("Installer failed with args {:?}: {}", args, last_error);
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = format!("Failed to execute installer: {e}");
|
||||
println!(
|
||||
"Failed to execute installer with args {:?}: {}",
|
||||
args, last_error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
return Err(
|
||||
format!(
|
||||
"EXE installation failed after trying multiple methods. Last error: {last_error}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"zip" => {
|
||||
// Handle ZIP files by extracting and replacing the current executable
|
||||
println!("Handling ZIP update: {}", installer_path.display());
|
||||
|
||||
let temp_extract_dir = installer_path.parent().unwrap().join("extracted");
|
||||
fs::create_dir_all(&temp_extract_dir)?;
|
||||
|
||||
// Extract ZIP file
|
||||
let extractor = crate::extraction::Extractor::new();
|
||||
let extracted_path = extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
let current_exe = self.get_current_app_path()?;
|
||||
let current_exe_name = current_exe.file_name().unwrap();
|
||||
|
||||
// Look for the new executable
|
||||
let new_exe_path =
|
||||
if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
|
||||
extracted_path
|
||||
} else {
|
||||
// Search in extracted directory
|
||||
let mut found_exe = None;
|
||||
if let Ok(entries) = fs::read_dir(&extracted_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.file_name() == Some(current_exe_name) {
|
||||
found_exe = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found_exe.ok_or("Could not find executable in ZIP file")?
|
||||
};
|
||||
|
||||
// Create backup of current executable
|
||||
let backup_path = current_exe.with_extension("exe.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_file(&backup_path)?;
|
||||
}
|
||||
fs::copy(¤t_exe, &backup_path)?;
|
||||
|
||||
// Replace current executable
|
||||
fs::copy(&new_exe_path, ¤t_exe)?;
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
||||
|
||||
println!("ZIP update completed successfully");
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Unsupported installer format: {extension}").into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// For Linux, we would handle different package formats here
|
||||
// This implementation would depend on the specific package type
|
||||
Err("Linux auto-update installation not yet implemented".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Auto-update installation not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 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());
|
||||
// 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;
|
||||
}
|
||||
current = parent;
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
}
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// On Windows, just return the current executable path
|
||||
std::env::current_exe().map_err(|e| e.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// On Linux, return the current executable path
|
||||
std::env::current_exe().map_err(|e| e.into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Platform not supported".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();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
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 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
|
||||
// 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
|
||||
@@ -485,37 +720,146 @@ open "{}"
|
||||
# Clean up this script
|
||||
rm "{}"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
// 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();
|
||||
// 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());
|
||||
// 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)]
|
||||
{
|
||||
// Detach the process completely
|
||||
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);
|
||||
}
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
// Create a temporary restart batch script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.bat");
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"@echo off
|
||||
rem Wait for the current process to exit
|
||||
:wait_loop
|
||||
tasklist /fi "PID eq {}" >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_loop
|
||||
)
|
||||
|
||||
rem Wait a bit more to ensure clean exit
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
rem Start the new application
|
||||
start "" "{}"
|
||||
|
||||
rem Clean up this script
|
||||
del "%~f0"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", script_path.to_str().unwrap()]);
|
||||
|
||||
// Start the process detached
|
||||
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);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
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
|
||||
"{}" &
|
||||
|
||||
# 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
|
||||
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);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Application restart not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-22
@@ -9,6 +9,8 @@ pub struct ProxySettings {
|
||||
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -216,17 +218,13 @@ mod linux {
|
||||
install_dir: &Path,
|
||||
browser_type: &BrowserType,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
let browser_subdir = install_dir.join(browser_type.as_str());
|
||||
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Chromium => vec![
|
||||
browser_subdir.join("chromium"),
|
||||
browser_subdir.join("chrome"),
|
||||
],
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Brave => vec![
|
||||
browser_subdir.join("brave"),
|
||||
browser_subdir.join("brave-browser"),
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -292,21 +290,13 @@ mod linux {
|
||||
}
|
||||
|
||||
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
let browser_subdir = install_dir.join(browser_type.as_str());
|
||||
|
||||
if !browser_subdir.exists() || !browser_subdir.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Chromium => vec![
|
||||
browser_subdir.join("chromium"),
|
||||
browser_subdir.join("chrome"),
|
||||
],
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Brave => vec![
|
||||
browser_subdir.join("brave"),
|
||||
browser_subdir.join("brave-browser"),
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -872,6 +862,8 @@ mod tests {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert!(proxy.enabled);
|
||||
@@ -885,6 +877,8 @@ mod tests {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "proxy.example.com".to_string(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert_eq!(socks_proxy.proxy_type, "socks5");
|
||||
@@ -961,6 +955,8 @@ mod tests {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Test that it can be serialized (implements Serialize)
|
||||
|
||||
+380
-138
@@ -3,7 +3,6 @@ use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::{Pid, System};
|
||||
use tauri::Emitter;
|
||||
@@ -35,6 +34,7 @@ pub struct BrowserProfile {
|
||||
mod macos {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn is_tor_or_mullvad_browser(exe_name: &str, cmd: &[OsString], browser_type: &str) -> bool {
|
||||
match browser_type {
|
||||
@@ -482,14 +482,42 @@ end try
|
||||
mod windows {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn is_tor_or_mullvad_browser(
|
||||
_exe_name: &str,
|
||||
_cmd: &[OsString],
|
||||
_browser_type: &str,
|
||||
) -> bool {
|
||||
// Windows implementation would go here
|
||||
false
|
||||
pub fn is_tor_or_mullvad_browser(exe_name: &str, cmd: &[OsString], browser_type: &str) -> bool {
|
||||
let exe_lower = exe_name.to_lowercase();
|
||||
|
||||
// Check for Firefox-based browsers first by executable name
|
||||
let is_firefox_family = exe_lower.contains("firefox") || exe_lower.contains(".exe");
|
||||
|
||||
if !is_firefox_family {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check command arguments for profile paths and browser-specific indicators
|
||||
let cmd_line = cmd
|
||||
.iter()
|
||||
.map(|s| s.to_string_lossy().to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
match browser_type {
|
||||
"tor-browser" => {
|
||||
// Check for TOR browser specific paths and arguments
|
||||
cmd_line.contains("tor")
|
||||
|| cmd_line.contains("browser\\torbrowser")
|
||||
|| cmd_line.contains("tor-browser")
|
||||
|| cmd_line.contains("profile") && (cmd_line.contains("tor") || cmd_line.contains("tbb"))
|
||||
}
|
||||
"mullvad-browser" => {
|
||||
// Check for Mullvad browser specific paths and arguments
|
||||
cmd_line.contains("mullvad")
|
||||
|| cmd_line.contains("browser\\mullvadbrowser")
|
||||
|| cmd_line.contains("mullvad-browser")
|
||||
|| cmd_line.contains("profile") && cmd_line.contains("mullvad")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_browser_process(
|
||||
@@ -500,7 +528,48 @@ mod windows {
|
||||
"Launching browser on Windows: {:?} with args: {:?}",
|
||||
executable_path, args
|
||||
);
|
||||
Ok(Command::new(executable_path).args(args).spawn()?)
|
||||
|
||||
// Check if the executable exists
|
||||
if !executable_path.exists() {
|
||||
return Err(format!("Browser executable not found: {:?}", executable_path).into());
|
||||
}
|
||||
|
||||
// On Windows, set up the command with proper working directory
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args(args);
|
||||
|
||||
// Set working directory to the executable's directory for better compatibility
|
||||
if let Some(parent_dir) = executable_path.parent() {
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
// For Windows 7 compatibility, set some environment variables
|
||||
cmd.env(
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
std::env::var("PROCESSOR_ARCHITECTURE").unwrap_or_else(|_| "x86".to_string()),
|
||||
);
|
||||
|
||||
// Ensure proper PATH for DLL loading
|
||||
if let Some(exe_dir) = executable_path.parent() {
|
||||
let mut path_var = std::env::var("PATH").unwrap_or_default();
|
||||
if !path_var.is_empty() {
|
||||
path_var = format!("{};{}", exe_dir.display(), path_var);
|
||||
} else {
|
||||
path_var = exe_dir.display().to_string();
|
||||
}
|
||||
cmd.env("PATH", path_var);
|
||||
}
|
||||
|
||||
// Launch the process
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch browser process: {}", e))?;
|
||||
|
||||
println!(
|
||||
"Successfully launched browser process with PID: {}",
|
||||
child.id()
|
||||
);
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
@@ -514,14 +583,88 @@ mod windows {
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let output = Command::new(executable_path)
|
||||
.args(["-profile", &profile.profile_path, "-new-tab", url])
|
||||
.output()?;
|
||||
// For Windows, try using the -requestPending approach for Firefox
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args([
|
||||
"-profile",
|
||||
&profile.profile_path,
|
||||
"-requestPending",
|
||||
"-new-tab",
|
||||
url,
|
||||
]);
|
||||
|
||||
// Set working directory
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Fallback: try without -requestPending
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
let mut fallback_cmd = Command::new(executable_path);
|
||||
fallback_cmd.args(["-profile", &profile.profile_path, "-new-tab", url]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_tor_mullvad(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// On Windows, TOR and Mullvad browsers can sometimes accept URLs via command line
|
||||
// even with -no-remote, by launching a new instance that hands off to existing one
|
||||
let browser = create_browser(browser_type.clone());
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let mut cmd = Command::new(&executable_path);
|
||||
cmd.args(["-profile", &profile.profile_path, url]);
|
||||
|
||||
// Set working directory
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
"Failed to open URL in existing {}: {}. Note: TOR and Mullvad browsers may require manual URL opening for security reasons.",
|
||||
browser_type.as_str(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
@@ -531,38 +674,57 @@ mod windows {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_tor_mullvad(
|
||||
_profile: &BrowserProfile,
|
||||
_url: &str,
|
||||
_browser_type: BrowserType,
|
||||
_browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Err("Opening URLs in existing Firefox-based browsers is not supported on Windows when using -no-remote".into())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_chromium(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let browser = create_browser(browser_type);
|
||||
let browser = create_browser(browser_type.clone());
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let output = Command::new(executable_path)
|
||||
.args([&format!("--user-data-dir={}", profile.profile_path), url])
|
||||
.output()?;
|
||||
let mut cmd = Command::new(&executable_path);
|
||||
cmd.args([
|
||||
&format!("--user-data-dir={}", profile.profile_path),
|
||||
"--new-window",
|
||||
url,
|
||||
]);
|
||||
|
||||
// Set working directory
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing Chromium-based browser: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
// Try fallback without --new-window
|
||||
let mut fallback_cmd = Command::new(&executable_path);
|
||||
fallback_cmd.args([&format!("--user-data-dir={}", profile.profile_path), url]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing Chromium-based browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -571,17 +733,41 @@ mod windows {
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// First try using sysinfo (cross-platform approach)
|
||||
let system = System::new_all();
|
||||
if let Some(process) = system.process(Pid::from(pid as usize)) {
|
||||
if !process.kill() {
|
||||
return Err(format!("Failed to kill process {}", pid).into());
|
||||
if process.kill() {
|
||||
println!("Successfully killed browser process with PID: {pid}");
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Process {} not found", pid).into());
|
||||
}
|
||||
|
||||
println!("Successfully killed browser process with PID: {pid}");
|
||||
Ok(())
|
||||
// Fallback to Windows-specific process termination
|
||||
use std::process::Command;
|
||||
|
||||
// Try taskkill command as fallback
|
||||
let output = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
println!("Successfully killed browser process with PID: {pid} using taskkill");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
format!(
|
||||
"Failed to kill process {} with taskkill: {}",
|
||||
pid,
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to execute taskkill for process {}: {}", pid, e).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +775,7 @@ mod windows {
|
||||
mod linux {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn is_tor_or_mullvad_browser(
|
||||
_exe_name: &str,
|
||||
@@ -904,11 +1091,12 @@ impl BrowserRunner {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_proxy(
|
||||
pub async fn update_profile_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
proxy: Option<ProxySettings>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_file = profiles_dir.join(format!(
|
||||
"{}.json",
|
||||
@@ -924,30 +1112,97 @@ impl BrowserRunner {
|
||||
let content = fs::read_to_string(&profile_file)?;
|
||||
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
|
||||
|
||||
// Check if browser is running to manage proxy accordingly
|
||||
let browser_is_running = profile.process_id.is_some()
|
||||
&& self
|
||||
.check_browser_status(app_handle.clone(), &profile)
|
||||
.await?;
|
||||
|
||||
// If browser is running, stop existing proxy
|
||||
if browser_is_running && profile.proxy.is_some() {
|
||||
if let Some(pid) = profile.process_id {
|
||||
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Update proxy settings
|
||||
profile.proxy = proxy.clone();
|
||||
|
||||
// Save the updated profile
|
||||
self.save_profile(&profile)?;
|
||||
self
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
// Get internal proxy if the browser is running
|
||||
let internal_proxy = if let Some(pid) = profile.process_id {
|
||||
PROXY_MANAGER.get_proxy_settings(pid)
|
||||
// Handle proxy startup/configuration
|
||||
if let Some(proxy_settings) = &proxy {
|
||||
if proxy_settings.enabled && browser_is_running {
|
||||
// Browser is running and proxy is enabled, start new proxy
|
||||
if let Some(pid) = profile.process_id {
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(app_handle.clone(), proxy_settings, pid, Some(profile_name))
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy_settings) => {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
|
||||
|
||||
// Apply the proxy settings with the internal proxy to the profile directory
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(
|
||||
&profile_path,
|
||||
proxy_settings,
|
||||
Some(&internal_proxy_settings),
|
||||
)
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
|
||||
println!("Successfully started proxy for profile: {}", profile.name);
|
||||
|
||||
// Give the proxy a moment to fully start up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
Some(internal_proxy_settings)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Apply proxy settings without internal proxy
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No PID available, apply proxy settings without internal proxy
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// Proxy disabled or browser not running, just apply settings
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// No proxy settings, disable proxy
|
||||
self
|
||||
.disable_proxy_settings_in_profile(&profile_path)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to disable proxy settings: {e}").into()
|
||||
})?;
|
||||
None
|
||||
};
|
||||
|
||||
// Apply proxy settings if provided
|
||||
if let Some(proxy_settings) = &proxy {
|
||||
self.apply_proxy_settings_to_profile(
|
||||
&profile_path,
|
||||
proxy_settings,
|
||||
internal_proxy.as_ref(),
|
||||
)?;
|
||||
} else {
|
||||
self.disable_proxy_settings_in_profile(&profile_path)?;
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -1062,9 +1317,10 @@ impl BrowserRunner {
|
||||
preferences.extend(self.get_common_firefox_preferences());
|
||||
|
||||
if proxy.enabled {
|
||||
// Create PAC file from template
|
||||
let template_path = Path::new("assets/template.pac");
|
||||
let pac_content = fs::read_to_string(template_path)?;
|
||||
// Use embedded PAC template instead of reading from file
|
||||
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
|
||||
return "{{proxy_url}}";
|
||||
}"#;
|
||||
|
||||
// Format proxy URL based on type and whether we have an internal proxy
|
||||
let proxy_url = if let Some(internal) = internal_proxy {
|
||||
@@ -1082,7 +1338,7 @@ impl BrowserRunner {
|
||||
};
|
||||
|
||||
// Replace placeholders in PAC file
|
||||
let pac_content = pac_content
|
||||
let pac_content = PAC_TEMPLATE
|
||||
.replace("{{proxy_url}}", &proxy_url)
|
||||
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
|
||||
|
||||
@@ -1201,9 +1457,9 @@ impl BrowserRunner {
|
||||
// Continue anyway, the error might not be critical
|
||||
}
|
||||
|
||||
// Get launch arguments
|
||||
// Get launch arguments (proxy settings will be handled later if needed)
|
||||
let browser_args = browser
|
||||
.create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url)
|
||||
.create_launch_args(&profile.profile_path, None, url)
|
||||
.expect("Failed to create launch arguments");
|
||||
|
||||
// Launch browser using platform-specific method
|
||||
@@ -1801,14 +2057,13 @@ impl BrowserRunner {
|
||||
|
||||
if !proxy_active {
|
||||
// Browser is running but proxy is not - restart the proxy
|
||||
if let Some((upstream_url, _preferred_port)) =
|
||||
PROXY_MANAGER.get_profile_proxy_info(&inner_profile.name)
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_profile_proxy_info(&inner_profile.name)
|
||||
{
|
||||
// Restart the proxy with the same configuration
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle,
|
||||
&upstream_url,
|
||||
&proxy_settings,
|
||||
inner_profile.process_id.unwrap(),
|
||||
Some(&inner_profile.name),
|
||||
)
|
||||
@@ -1964,9 +2219,53 @@ pub async fn launch_browser_profile(
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
|
||||
// If the profile has proxy settings, we need to start the proxy first
|
||||
// and update the profile with proxy settings before launching
|
||||
let profile_for_launch = profile.clone();
|
||||
if let Some(proxy) = &profile.proxy {
|
||||
if proxy.enabled {
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
|
||||
// Start the proxy first
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(app_handle.clone(), proxy, temp_pid, Some(&profile.name))
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy_settings) => {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
|
||||
|
||||
// Apply the proxy settings with the internal proxy to the profile directory
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy, Some(&internal_proxy_settings))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
|
||||
println!("Successfully started proxy for profile: {}", profile.name);
|
||||
|
||||
// Give the proxy a moment to fully start up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Still continue with browser launch, but without proxy
|
||||
let browser_runner = BrowserRunner::new();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
|
||||
|
||||
// Apply proxy settings without internal proxy
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy, None)
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch browser or open URL in existing instance
|
||||
let updated_profile = browser_runner
|
||||
.launch_or_open_url(app_handle.clone(), &profile, url)
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Check if this is an architecture compatibility issue
|
||||
@@ -1979,32 +2278,17 @@ pub async fn launch_browser_profile(
|
||||
format!("Failed to launch browser or open URL: {e}")
|
||||
})?;
|
||||
|
||||
// If the profile has proxy settings, start a proxy for it
|
||||
// Now update the proxy with the correct PID if we have one
|
||||
if let Some(proxy) = &profile.proxy {
|
||||
if proxy.enabled {
|
||||
// Get the process ID
|
||||
if let Some(pid) = updated_profile.process_id {
|
||||
// Start a proxy for the upstream URL
|
||||
let upstream_url = format!("{}://{}:{}", proxy.proxy_type, proxy.host, proxy.port);
|
||||
|
||||
// Start the proxy
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(app_handle.clone(), &upstream_url, pid, Some(&profile.name))
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy_settings) => {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.name.to_lowercase().replace(" ", "_"));
|
||||
|
||||
// Apply the proxy settings with the internal proxy
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, proxy, Some(&internal_proxy_settings))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
if let Some(actual_pid) = updated_profile.process_id {
|
||||
// Update the proxy manager with the correct PID
|
||||
match PROXY_MANAGER.update_proxy_pid(1u32, actual_pid) {
|
||||
Ok(()) => {
|
||||
println!("Updated proxy PID mapping from temp (1) to actual PID: {actual_pid}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Continue without proxy
|
||||
eprintln!("Failed to update proxy PID mapping: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2015,13 +2299,15 @@ pub async fn launch_browser_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_proxy(
|
||||
pub async fn update_profile_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: String,
|
||||
proxy: Option<ProxySettings>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let browser_runner = BrowserRunner::new();
|
||||
browser_runner
|
||||
.update_profile_proxy(&profile_name, proxy)
|
||||
.update_profile_proxy(app_handle, &profile_name, proxy)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
@@ -2441,6 +2727,8 @@ mod tests {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
let profile = runner
|
||||
@@ -2479,36 +2767,6 @@ mod tests {
|
||||
assert_eq!(profiles[0].version, "139.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_profile_proxy() {
|
||||
let (runner, _temp_dir) = create_test_browser_runner();
|
||||
|
||||
// Create profile without proxy
|
||||
let profile = runner
|
||||
.create_profile("Test Update Proxy", "firefox", "139.0", None)
|
||||
.unwrap();
|
||||
|
||||
assert!(profile.proxy.is_none());
|
||||
|
||||
// Update with proxy
|
||||
let proxy = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "192.168.1.1".to_string(),
|
||||
port: 1080,
|
||||
};
|
||||
|
||||
let updated_profile = runner
|
||||
.update_profile_proxy("Test Update Proxy", Some(proxy.clone()))
|
||||
.unwrap();
|
||||
|
||||
assert!(updated_profile.proxy.is_some());
|
||||
let profile_proxy = updated_profile.proxy.unwrap();
|
||||
assert_eq!(profile_proxy.proxy_type, "socks5");
|
||||
assert_eq!(profile_proxy.host, "192.168.1.1");
|
||||
assert_eq!(profile_proxy.port, 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_profile() {
|
||||
let (runner, _temp_dir) = create_test_browser_runner();
|
||||
@@ -2606,24 +2864,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().to_string().contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling() {
|
||||
let (runner, _temp_dir) = create_test_browser_runner();
|
||||
|
||||
// Test updating non-existent profile
|
||||
let result = runner.update_profile_proxy("Non Existent", None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
|
||||
// Test deleting non-existent profile
|
||||
let result = runner.delete_profile("Non Existent");
|
||||
assert!(result.is_ok()); // Delete should be idempotent
|
||||
|
||||
// Test renaming non-existent profile
|
||||
let result = runner.rename_profile("Non Existent", "New Name");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_firefox_default_browser_preferences() {
|
||||
let (runner, _temp_dir) = create_test_browser_runner();
|
||||
@@ -2651,6 +2891,8 @@ mod tests {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
let profile_with_proxy = runner
|
||||
|
||||
@@ -442,8 +442,8 @@ impl BrowserVersionService {
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.bz2"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.bz2"), true),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
@@ -468,8 +468,8 @@ impl BrowserVersionService {
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.bz2"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.bz2"), true),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
@@ -568,8 +568,6 @@ impl BrowserVersionService {
|
||||
})
|
||||
}
|
||||
"brave" => {
|
||||
// Brave uses different asset naming conventions
|
||||
// The actual URL will be resolved dynamically in the download service
|
||||
let (filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", _) => (format!("brave-{version}.exe"), false),
|
||||
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
|
||||
@@ -582,7 +580,7 @@ impl BrowserVersionService {
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
@@ -1521,10 +1519,10 @@ mod tests {
|
||||
assert!(chromium_info.url.contains("chrome-mac.zip"));
|
||||
assert!(chromium_info.is_archive);
|
||||
|
||||
// Test Brave
|
||||
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
|
||||
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
|
||||
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
|
||||
assert!(brave_info.url.contains("brave-placeholder"));
|
||||
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
|
||||
assert!(brave_info.is_archive);
|
||||
|
||||
// Test unsupported browser
|
||||
|
||||
@@ -65,13 +65,282 @@ mod macos {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use std::path::Path;
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
const APP_NAME: &str = "DonutBrowser";
|
||||
const PROG_ID: &str = "DonutBrowser.HTML";
|
||||
|
||||
pub fn is_default_browser() -> Result<bool, String> {
|
||||
// Windows implementation would go here
|
||||
Err("Windows support not implemented yet".to_string())
|
||||
let schemes = ["http", "https"];
|
||||
|
||||
for scheme in schemes {
|
||||
// Check if our browser is set as the default handler for this scheme
|
||||
if !is_default_for_scheme(scheme)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_as_default_browser() -> Result<(), String> {
|
||||
Err("Windows support not implemented yet".to_string())
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
||||
|
||||
let exe_path_str = exe_path
|
||||
.to_str()
|
||||
.ok_or("Failed to convert executable path to string")?;
|
||||
|
||||
// Verify the executable exists
|
||||
if !Path::new(exe_path_str).exists() {
|
||||
return Err(format!("Executable not found at: {}", exe_path_str));
|
||||
}
|
||||
|
||||
// Register the application
|
||||
register_application(exe_path_str)?;
|
||||
|
||||
// Set as default for HTTP and HTTPS
|
||||
set_default_for_scheme("http")?;
|
||||
set_default_for_scheme("https")?;
|
||||
|
||||
// Register file associations for HTML files
|
||||
register_html_file_association(exe_path_str)?;
|
||||
|
||||
// Notify the system of changes
|
||||
notify_system_of_changes();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
|
||||
let path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
|
||||
match hkcu.open_subkey(&path) {
|
||||
Ok(key) => match key.get_value::<String, _>("ProgId") {
|
||||
Ok(prog_id) => Ok(prog_id == PROG_ID),
|
||||
Err(_) => Ok(false),
|
||||
},
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_application(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Register in Software\RegisteredApplications
|
||||
let (registered_apps, _) = hkcu
|
||||
.create_subkey("Software\\RegisteredApplications")
|
||||
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
|
||||
|
||||
registered_apps
|
||||
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to set registered application: {}", e))?;
|
||||
|
||||
// Create application key
|
||||
let (app_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to create application key: {}", e))?;
|
||||
|
||||
// Set application properties
|
||||
app_key
|
||||
.set_value("ApplicationName", &APP_NAME)
|
||||
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value("ApplicationIcon", &format!("{},0", exe_path))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
|
||||
|
||||
// Create Capabilities key
|
||||
let (capabilities, _) = app_key
|
||||
.create_subkey("Capabilities")
|
||||
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
|
||||
|
||||
capabilities
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
|
||||
|
||||
// Set URL associations
|
||||
let (url_assoc, _) = capabilities
|
||||
.create_subkey("URLAssociations")
|
||||
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("http", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set http association: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("https", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set https association: {}", e))?;
|
||||
|
||||
// Set file associations
|
||||
let (file_assoc, _) = capabilities
|
||||
.create_subkey("FileAssociations")
|
||||
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".html", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .html association: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".htm", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
|
||||
|
||||
// Register the ProgID
|
||||
register_prog_id(exe_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_prog_id(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Create ProgID key
|
||||
let (prog_id_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
|
||||
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("FriendlyTypeName", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
|
||||
|
||||
// Create DefaultIcon key
|
||||
let (icon_key, _) = prog_id_key
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
|
||||
|
||||
icon_key
|
||||
.set_value("", &format!("{},0", exe_path))
|
||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
||||
|
||||
// Create shell\open\command key
|
||||
let (command_key, _) = prog_id_key
|
||||
.create_subkey("shell\\open\\command")
|
||||
.map_err(|e| format!("Failed to create command key: {}", e))?;
|
||||
|
||||
command_key
|
||||
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
|
||||
.map_err(|e| format!("Failed to set command: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
|
||||
// Note: On Windows 10+, this might require elevated permissions or user interaction
|
||||
// through the Settings app due to security restrictions
|
||||
|
||||
// Try to set the association in the user's choice
|
||||
let user_choice_path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
|
||||
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
|
||||
// The user may need to manually set the default browser through Windows Settings
|
||||
match hkcu.create_subkey(&user_choice_path) {
|
||||
Ok((user_choice, _)) => {
|
||||
// Attempt to set the ProgId
|
||||
if let Err(_) = user_choice.set_value("ProgId", &PROG_ID) {
|
||||
// If we can't set UserChoice, that's expected on newer Windows versions
|
||||
// The registration is still valuable for the "Open with" menu
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Expected on newer Windows versions - user must set manually
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Register .html and .htm file associations
|
||||
for ext in &[".html", ".htm"] {
|
||||
let ext_path = format!("Software\\Classes\\{}", ext);
|
||||
|
||||
match hkcu.create_subkey(&ext_path) {
|
||||
Ok((ext_key, _)) => {
|
||||
// Set the default value to our ProgID
|
||||
let _ = ext_key.set_value("", &PROG_ID);
|
||||
}
|
||||
Err(_) => {
|
||||
// Continue if we can't set the file association
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn notify_system_of_changes() {
|
||||
// Use Windows API to notify the system of association changes
|
||||
// This helps refresh the system's understanding of the changes
|
||||
unsafe {
|
||||
use std::ffi::c_void;
|
||||
|
||||
// Declare the Windows API functions
|
||||
type UINT = u32;
|
||||
type DWORD = u32;
|
||||
type LPARAM = isize;
|
||||
type WPARAM = usize;
|
||||
|
||||
const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void;
|
||||
const WM_SETTINGCHANGE: UINT = 0x001A;
|
||||
const SMTO_ABORTIFHUNG: UINT = 0x0002;
|
||||
|
||||
// Link to user32.dll functions
|
||||
extern "system" {
|
||||
fn SendMessageTimeoutA(
|
||||
hWnd: *mut c_void,
|
||||
Msg: UINT,
|
||||
wParam: WPARAM,
|
||||
lParam: LPARAM,
|
||||
fuFlags: UINT,
|
||||
uTimeout: UINT,
|
||||
lpdwResult: *mut DWORD,
|
||||
) -> isize;
|
||||
}
|
||||
|
||||
let mut result: DWORD = 0;
|
||||
|
||||
// Notify about file associations change
|
||||
SendMessageTimeoutA(
|
||||
HWND_BROADCAST,
|
||||
WM_SETTINGCHANGE,
|
||||
0,
|
||||
"Software\\Classes\0".as_ptr() as LPARAM,
|
||||
SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
&mut result,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,40 +195,13 @@ impl Downloader {
|
||||
})
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
|
||||
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
|
||||
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
|
||||
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to DEB packages
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains(arch_pattern) && name.ends_with(".deb")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any ZIP
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.ends_with(".zip")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any DEB
|
||||
assets.iter().find(|asset| asset.name.ends_with(".deb"))
|
||||
})
|
||||
.or_else(|| {
|
||||
// Last fallback to RPM if no ZIP or DEB found
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("x86_64") && name.ends_with(".rpm")
|
||||
})
|
||||
})
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
+279
-42
@@ -453,8 +453,13 @@ impl Extractor {
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Use PowerShell's Expand-Archive on Windows
|
||||
let output = Command::new("powershell")
|
||||
println!("Extracting ZIP archive on Windows: {}", zip_path.display());
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
fs::create_dir_all(dest_dir)?;
|
||||
|
||||
// First try PowerShell's Expand-Archive (Windows 10+)
|
||||
let powershell_result = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
&format!(
|
||||
@@ -463,21 +468,81 @@ impl Extractor {
|
||||
dest_dir.display()
|
||||
),
|
||||
])
|
||||
.output()?;
|
||||
.output();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to extract zip with PowerShell: {}",
|
||||
match powershell_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
println!("Successfully extracted using PowerShell");
|
||||
}
|
||||
Ok(output) => {
|
||||
println!(
|
||||
"PowerShell extraction failed: {}, trying Rust zip crate fallback",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
);
|
||||
// Fallback to Rust zip crate for Windows 7 compatibility
|
||||
return self.extract_zip_with_rust_crate(zip_path, dest_dir).await;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("PowerShell not available: {}, using Rust zip crate", e);
|
||||
// Fallback to Rust zip crate for Windows 7 compatibility
|
||||
return self.extract_zip_with_rust_crate(zip_path, dest_dir).await;
|
||||
}
|
||||
}
|
||||
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn extract_zip_with_rust_crate(
|
||||
&self,
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Using Rust zip crate for extraction (Windows 7+ compatibility)");
|
||||
|
||||
let file = fs::File::open(zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => dest_dir.join(path),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Handle directory creation
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
// Create parent directories
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract file
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
|
||||
// On Windows, verify executable files
|
||||
if outpath
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
|
||||
{
|
||||
if let Ok(metadata) = fs::metadata(&outpath) {
|
||||
if metadata.len() > 0 {
|
||||
println!("Extracted executable: {}", outpath.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("ZIP extraction completed. Searching for executable...");
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
async fn extract_zip_unix(
|
||||
&self,
|
||||
@@ -514,24 +579,60 @@ impl Extractor {
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
create_dir_all(dest_dir)?;
|
||||
|
||||
// Use tar command for more reliable extraction
|
||||
let output = Command::new("tar")
|
||||
.args([
|
||||
"-xf",
|
||||
tar_path.to_str().unwrap(),
|
||||
"-C",
|
||||
dest_dir.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// On Windows, try multiple extraction methods for better compatibility
|
||||
// First try using tar if available (Windows 10+)
|
||||
let tar_result = Command::new("tar")
|
||||
.args([
|
||||
"-xf",
|
||||
tar_path.to_str().unwrap(),
|
||||
"-C",
|
||||
dest_dir.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to extract tar.xz: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
match tar_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
println!("Successfully extracted tar.xz using tar command");
|
||||
}
|
||||
Ok(output) => {
|
||||
println!(
|
||||
"tar command failed: {}, trying 7-Zip fallback",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
// Try 7-Zip as fallback
|
||||
return self.extract_with_7zip(tar_path, dest_dir).await;
|
||||
}
|
||||
Err(_) => {
|
||||
println!("tar command not available, trying 7-Zip");
|
||||
// Try 7-Zip as fallback
|
||||
return self.extract_with_7zip(tar_path, dest_dir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Use tar command for Unix-like systems
|
||||
let output = Command::new("tar")
|
||||
.args([
|
||||
"-xf",
|
||||
tar_path.to_str().unwrap(),
|
||||
"-C",
|
||||
dest_dir.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to extract tar.xz: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the extracted executable and set proper permissions
|
||||
@@ -545,6 +646,44 @@ impl Extractor {
|
||||
Ok(executable_path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn extract_with_7zip(
|
||||
&self,
|
||||
archive_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Try to use 7-Zip for extraction (common on Windows)
|
||||
let seven_zip_paths = [
|
||||
"7z", // If 7z is in PATH
|
||||
"C:\\Program Files\\7-Zip\\7z.exe",
|
||||
"C:\\Program Files (x86)\\7-Zip\\7z.exe",
|
||||
];
|
||||
|
||||
for seven_zip_path in &seven_zip_paths {
|
||||
let result = Command::new(seven_zip_path)
|
||||
.args([
|
||||
"x", // Extract with full paths
|
||||
archive_path.to_str().unwrap(),
|
||||
&format!("-o{}", dest_dir.display()), // Output directory
|
||||
"-y", // Yes to all
|
||||
])
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
println!("Successfully extracted using 7-Zip: {}", seven_zip_path);
|
||||
return self.find_extracted_executable(dest_dir).await;
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
"No suitable extraction tool found. Please install 7-Zip or ensure tar is available.".into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn extract_tar_bz2(
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
@@ -827,40 +966,138 @@ impl Extractor {
|
||||
&self,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!(
|
||||
"Searching for Windows executable in: {}",
|
||||
dest_dir.display()
|
||||
);
|
||||
|
||||
// Look for .exe files, preferring main browser executables
|
||||
let exe_names = [
|
||||
"chrome.exe",
|
||||
let priority_exe_names = [
|
||||
"firefox.exe",
|
||||
"chrome.exe",
|
||||
"chromium.exe",
|
||||
"zen.exe",
|
||||
"brave.exe",
|
||||
"tor-browser.exe",
|
||||
"tor.exe",
|
||||
"mullvad-browser.exe",
|
||||
];
|
||||
|
||||
for exe_name in &exe_names {
|
||||
// First try priority executable names
|
||||
for exe_name in &priority_exe_names {
|
||||
let exe_path = dest_dir.join(exe_name);
|
||||
if exe_path.exists() {
|
||||
println!("Found priority executable: {}", exe_path.display());
|
||||
return Ok(exe_path);
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific executable found, look for any .exe file
|
||||
if let Ok(entries) = fs::read_dir(dest_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
return Ok(path);
|
||||
// Recursively search for executables with depth limit
|
||||
match self.find_windows_executable_recursive(dest_dir, 0, 3).await {
|
||||
Ok(exe_path) => {
|
||||
println!(
|
||||
"Found executable via recursive search: {}",
|
||||
exe_path.display()
|
||||
);
|
||||
Ok(exe_path)
|
||||
}
|
||||
Err(_) => {
|
||||
// List directory contents for debugging
|
||||
if let Ok(entries) = fs::read_dir(dest_dir) {
|
||||
println!("Directory contents:");
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let metadata = if path.is_dir() { "dir" } else { "file" };
|
||||
println!(" - {} ({})", path.display(), metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Check subdirectories
|
||||
if path.is_dir() {
|
||||
if let Ok(sub_result) = self.find_windows_executable(&path).await {
|
||||
return Ok(sub_result);
|
||||
Err("No executable found after extraction".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn find_windows_executable_recursive<'a>(
|
||||
&'a self,
|
||||
dir: &'a Path,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<Output = Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>>
|
||||
+ Send
|
||||
+ 'a,
|
||||
>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
if depth > max_depth {
|
||||
return Err("Maximum search depth reached".into());
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
let mut dirs_to_search = Vec::new();
|
||||
|
||||
// First pass: look for .exe files in current directory
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
|
||||
{
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
// Check if it's a browser executable
|
||||
if file_name.contains("firefox")
|
||||
|| file_name.contains("chrome")
|
||||
|| file_name.contains("chromium")
|
||||
|| file_name.contains("zen")
|
||||
|| file_name.contains("brave")
|
||||
|| file_name.contains("tor")
|
||||
|| file_name.contains("mullvad")
|
||||
|| file_name.contains("browser")
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// Collect directories for later search
|
||||
dirs_to_search.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: search subdirectories
|
||||
for subdir in dirs_to_search {
|
||||
if let Ok(result) = self
|
||||
.find_windows_executable_recursive(&subdir, depth + 1, max_depth)
|
||||
.await
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: if no browser-specific executable found, return any .exe
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe")
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("No executable found after extraction".into())
|
||||
Err("No executable found".into())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
+68
-20
@@ -1,4 +1,5 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
@@ -111,20 +112,16 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
// Check if the main window exists and is ready
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
// Window is visible, emit event directly
|
||||
println!("Main window is visible, emitting show-profile-selector event");
|
||||
app
|
||||
.emit("show-profile-selector", url.clone())
|
||||
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
// Window not visible yet - add to pending URLs
|
||||
println!("Main window not visible, adding URL to pending list");
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
pending.push(url);
|
||||
}
|
||||
println!("Main window exists");
|
||||
|
||||
// Try to show and focus the window first
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
|
||||
app
|
||||
.emit("show-profile-selector", url.clone())
|
||||
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
|
||||
} else {
|
||||
// Window doesn't exist yet - add to pending URLs
|
||||
println!("Main window doesn't exist, adding URL to pending list");
|
||||
@@ -137,6 +134,8 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
println!("check_and_handle_startup_url called");
|
||||
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
@@ -144,12 +143,24 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
|
||||
urls
|
||||
};
|
||||
|
||||
println!("Found {} pending URLs", pending_urls.len());
|
||||
|
||||
if !pending_urls.is_empty() {
|
||||
println!(
|
||||
"Handling {} pending URLs from frontend request",
|
||||
pending_urls.len()
|
||||
);
|
||||
|
||||
// Ensure the main window is visible and focused
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
|
||||
// Give the window a moment to become visible
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Emitting show-profile-selector event for URL: {url}");
|
||||
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
|
||||
@@ -166,12 +177,25 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
|
||||
|
||||
if let Some(url) = startup_url.clone() {
|
||||
println!("Found startup URL in command line: {url}");
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
pending.push(url.clone());
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
|
||||
println!("Single instance triggered with args: {args:?}");
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.setup(|app| {
|
||||
// Create the main window programmatically
|
||||
#[allow(unused_variables)]
|
||||
@@ -179,7 +203,10 @@ pub fn run() {
|
||||
.title("Donut Browser")
|
||||
.inner_size(900.0, 600.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false);
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
.focused(true)
|
||||
.visible(true);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
@@ -198,16 +225,27 @@ pub fn run() {
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
// For Windows and Linux, register all deep links at runtime for development
|
||||
app.deep_link().register_all()?;
|
||||
if let Err(e) = app.deep_link().register_all() {
|
||||
eprintln!("Failed to register deep links: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS, try to register deep links for development builds
|
||||
if let Err(e) = app.deep_link().register_all() {
|
||||
eprintln!(
|
||||
"Note: Deep link registration failed on macOS (this is normal for production): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep links - this works for both scenarios:
|
||||
// 1. App is running and URL is opened
|
||||
// 2. App is not running and URL causes app to launch
|
||||
app.deep_link().on_open_url({
|
||||
let handle = handle.clone();
|
||||
move |event| {
|
||||
let urls = event.urls();
|
||||
println!("Deep link event received with {} URLs", urls.len());
|
||||
|
||||
for url in urls {
|
||||
let url_string = url.to_string();
|
||||
println!("Deep link received: {url_string}");
|
||||
@@ -225,6 +263,16 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(startup_url) = startup_url {
|
||||
let handle_clone = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Processing startup URL from command line: {startup_url}");
|
||||
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
|
||||
eprintln!("Failed to handle startup URL: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize and start background version updater
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
|
||||
@@ -55,6 +55,9 @@ impl ProfileImporter {
|
||||
// Detect Zen Browser profiles
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
// Detect TOR Browser profiles
|
||||
detected_profiles.extend(self.detect_tor_browser_profiles()?);
|
||||
|
||||
// Remove duplicates based on path
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
@@ -80,9 +83,16 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
// Also check AppData\Local for portable installations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +127,11 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
// Firefox Developer Edition on Windows typically uses separate directories
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
// Firefox Developer Edition on Windows typically uses separate directories
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +165,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -186,10 +194,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let chromium_dir = local_app_data.join("Chromium/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let chromium_dir = local_app_data.join("Chromium/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -216,10 +223,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -251,9 +257,16 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
|
||||
// Also check common installation locations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
|
||||
if mullvad_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +296,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -298,6 +310,107 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect TOR Browser profiles
|
||||
fn detect_tor_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// TOR Browser on macOS is typically in Applications
|
||||
let tor_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/TorBrowser-Data/Browser/profile.default");
|
||||
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - Default Profile".to_string(),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: "Default TOR Browser profile".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Check common TOR Browser installation locations on Windows
|
||||
let possible_paths = [
|
||||
// Default installation in user directory
|
||||
(
|
||||
"Desktop",
|
||||
"Desktop/Tor Browser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
// AppData locations
|
||||
(
|
||||
"AppData/Roaming",
|
||||
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
(
|
||||
"AppData/Local",
|
||||
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
];
|
||||
|
||||
let home_dir = self.base_dirs.home_dir();
|
||||
|
||||
for (location_name, relative_path) in &possible_paths {
|
||||
let tor_dir = home_dir.join(relative_path);
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: format!("TOR Browser - {} Profile", location_name),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: format!("TOR Browser profile from {}", location_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also check AppData directories if available
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let tor_app_data =
|
||||
app_data.join("TorBrowser/Browser/TorBrowser/Data/Browser/profile.default");
|
||||
if tor_app_data.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - AppData Profile".to_string(),
|
||||
path: tor_app_data.to_string_lossy().to_string(),
|
||||
description: "TOR Browser profile from AppData".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Common TOR Browser locations on Linux
|
||||
let possible_paths = [
|
||||
".local/share/torbrowser/tbb/x86_64/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
"tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
".tor-browser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
"Downloads/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
];
|
||||
|
||||
let home_dir = self.base_dirs.home_dir();
|
||||
|
||||
for relative_path in &possible_paths {
|
||||
let tor_dir = home_dir.join(relative_path);
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - Default Profile".to_string(),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: "TOR Browser profile".to_string(),
|
||||
});
|
||||
break; // Only add the first one found to avoid duplicates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Scan Firefox-style profiles directory
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
|
||||
+595
-23
@@ -11,7 +11,9 @@ use crate::browser::ProxySettings;
|
||||
pub struct ProxyInfo {
|
||||
pub id: String,
|
||||
pub local_url: String,
|
||||
pub upstream_url: String,
|
||||
pub upstream_host: String,
|
||||
pub upstream_port: u16,
|
||||
pub upstream_type: String,
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
@@ -19,7 +21,7 @@ pub struct ProxyInfo {
|
||||
pub struct ProxyManager {
|
||||
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
|
||||
// Store proxy info by profile name for persistence across browser restarts
|
||||
profile_proxies: Mutex<HashMap<String, (String, u16)>>, // Maps profile name to (upstream_url, port)
|
||||
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
|
||||
}
|
||||
|
||||
impl ProxyManager {
|
||||
@@ -30,11 +32,11 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Start a proxy for a given upstream URL and associate it with a browser process ID
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
upstream_url: &str,
|
||||
proxy_settings: &ProxySettings,
|
||||
browser_pid: u32,
|
||||
profile_name: Option<&str>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
@@ -44,9 +46,11 @@ impl ProxyManager {
|
||||
if let Some(proxy) = proxies.get(&browser_pid) {
|
||||
return Ok(ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
proxy_type: proxy.upstream_type.clone(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,31 +58,62 @@ impl ProxyManager {
|
||||
// Check if we have a preferred port for this profile
|
||||
let preferred_port = if let Some(name) = profile_name {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(name).map(|(_, port)| *port)
|
||||
profile_proxies.get(name).and_then(|settings| {
|
||||
// Find existing proxy with same settings to reuse port
|
||||
let active_proxies = self.active_proxies.lock().unwrap();
|
||||
active_proxies
|
||||
.values()
|
||||
.find(|p| {
|
||||
p.upstream_host == settings.host
|
||||
&& p.upstream_port == settings.port
|
||||
&& p.upstream_type == settings.proxy_type
|
||||
})
|
||||
.map(|p| p.local_port)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start a new proxy using the nodecar binary
|
||||
// Start a new proxy using the nodecar binary with the correct CLI interface
|
||||
let mut nodecar = app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.unwrap()
|
||||
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("-u")
|
||||
.arg(upstream_url);
|
||||
.arg("--host")
|
||||
.arg(&proxy_settings.host)
|
||||
.arg("--proxy-port")
|
||||
.arg(proxy_settings.port.to_string())
|
||||
.arg("--type")
|
||||
.arg(&proxy_settings.proxy_type);
|
||||
|
||||
// Add credentials if provided
|
||||
if let Some(username) = &proxy_settings.username {
|
||||
nodecar = nodecar.arg("--username").arg(username);
|
||||
}
|
||||
if let Some(password) = &proxy_settings.password {
|
||||
nodecar = nodecar.arg("--password").arg(password);
|
||||
}
|
||||
|
||||
// If we have a preferred port, use it
|
||||
if let Some(port) = preferred_port {
|
||||
nodecar = nodecar.arg("-p").arg(port.to_string());
|
||||
nodecar = nodecar.arg("--port").arg(port.to_string());
|
||||
}
|
||||
|
||||
let output = nodecar.output().await.unwrap();
|
||||
// Execute the command and wait for it to complete
|
||||
// The nodecar binary should start the worker and then exit
|
||||
let output = nodecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Proxy start failed: {stderr}"));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(format!(
|
||||
"Proxy start failed - stdout: {stdout}, stderr: {stderr}"
|
||||
));
|
||||
}
|
||||
|
||||
let json_string =
|
||||
@@ -95,15 +130,13 @@ impl ProxyManager {
|
||||
.as_str()
|
||||
.ok_or("Missing local URL")?
|
||||
.to_string();
|
||||
let upstream_url_str = json["upstreamUrl"]
|
||||
.as_str()
|
||||
.ok_or("Missing upstream URL")?
|
||||
.to_string();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
id: id.to_string(),
|
||||
local_url,
|
||||
upstream_url: upstream_url_str.clone(),
|
||||
upstream_host: proxy_settings.host.clone(),
|
||||
upstream_port: proxy_settings.port,
|
||||
upstream_type: proxy_settings.proxy_type.clone(),
|
||||
local_port,
|
||||
};
|
||||
|
||||
@@ -116,15 +149,17 @@ impl ProxyManager {
|
||||
// Store the profile proxy info for persistence
|
||||
if let Some(name) = profile_name {
|
||||
let mut profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert(name.to_string(), (upstream_url_str, local_port));
|
||||
profile_proxies.insert(name.to_string(), proxy_settings.clone());
|
||||
}
|
||||
|
||||
// Return proxy settings for the browser
|
||||
Ok(ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy_info.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,19 +204,556 @@ impl ProxyManager {
|
||||
proxies.get(&browser_pid).map(|proxy| ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored proxy info for a profile
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(profile_name).cloned()
|
||||
}
|
||||
|
||||
// Update the PID mapping for an existing proxy
|
||||
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
if let Some(proxy_info) = proxies.remove(&old_pid) {
|
||||
proxies.insert(new_pid, proxy_info);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("No proxy found for PID {old_pid}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance of the proxy manager
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
// Mock HTTP server for testing
|
||||
|
||||
use http_body_util::Full;
|
||||
use hyper::body::Bytes;
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::Response;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// Helper function to build nodecar binary for testing
|
||||
async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
|
||||
let project_root = PathBuf::from(cargo_manifest_dir)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let nodecar_dir = project_root.join("nodecar");
|
||||
let nodecar_dist = nodecar_dir.join("dist");
|
||||
let nodecar_binary = nodecar_dist.join("nodecar");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
return Ok(nodecar_binary);
|
||||
}
|
||||
|
||||
// Build the nodecar binary
|
||||
println!("Building nodecar binary for tests...");
|
||||
|
||||
// Install dependencies
|
||||
let install_status = Command::new("pnpm")
|
||||
.args(["install", "--frozen-lockfile"])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !install_status.success() {
|
||||
return Err("Failed to install nodecar dependencies".into());
|
||||
}
|
||||
|
||||
// Determine the target architecture
|
||||
let target = if cfg!(target_arch = "aarch64") && cfg!(target_os = "macos") {
|
||||
"build:mac-aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "macos") {
|
||||
"build:mac-x86_64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "linux") {
|
||||
"build:linux-x64"
|
||||
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "linux") {
|
||||
"build:linux-arm64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "windows") {
|
||||
"build:win-x64"
|
||||
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "windows") {
|
||||
"build:win-arm64"
|
||||
} else {
|
||||
return Err("Unsupported target architecture for nodecar build".into());
|
||||
};
|
||||
|
||||
// Build the binary
|
||||
let build_status = Command::new("pnpm")
|
||||
.args(["run", target])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !build_status.success() {
|
||||
return Err("Failed to build nodecar binary".into());
|
||||
}
|
||||
|
||||
if !nodecar_binary.exists() {
|
||||
return Err("Nodecar binary was not created successfully".into());
|
||||
}
|
||||
|
||||
Ok(nodecar_binary)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_profile_persistence() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Test profile proxy info storage
|
||||
{
|
||||
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
|
||||
}
|
||||
|
||||
// Test retrieval
|
||||
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.proxy_type, "socks5");
|
||||
assert_eq!(retrieved.host, "127.0.0.1");
|
||||
assert_eq!(retrieved.port, 1080);
|
||||
|
||||
// Test non-existent profile
|
||||
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_active_proxy_tracking() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
id: "test_proxy_123".to_string(),
|
||||
local_url: "http://localhost:8080".to_string(),
|
||||
upstream_host: "proxy.example.com".to_string(),
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 8080,
|
||||
};
|
||||
|
||||
let browser_pid = 54321u32;
|
||||
|
||||
// Add active proxy
|
||||
{
|
||||
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
|
||||
active_proxies.insert(browser_pid, proxy_info.clone());
|
||||
}
|
||||
|
||||
// Test retrieval of proxy settings
|
||||
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
|
||||
assert!(proxy_settings.is_some());
|
||||
let settings = proxy_settings.unwrap();
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.host, "127.0.0.1");
|
||||
assert_eq!(settings.port, 8080);
|
||||
|
||||
// Test non-existent browser PID
|
||||
let non_existent = proxy_manager.get_proxy_settings(99999);
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_settings_validation() {
|
||||
// Test valid proxy settings
|
||||
let valid_settings = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pass".to_string()),
|
||||
};
|
||||
|
||||
assert!(valid_settings.enabled);
|
||||
assert_eq!(valid_settings.proxy_type, "http");
|
||||
assert!(!valid_settings.host.is_empty());
|
||||
assert!(valid_settings.port > 0);
|
||||
|
||||
// Test disabled proxy settings
|
||||
let disabled_settings = ProxySettings {
|
||||
enabled: false,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert!(!disabled_settings.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_concurrent_access() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let proxy_manager = Arc::new(ProxyManager::new());
|
||||
let mut handles = vec![];
|
||||
|
||||
// Spawn multiple tasks that access the proxy manager concurrently
|
||||
for i in 0..10 {
|
||||
let pm = proxy_manager.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let browser_pid = (1000 + i) as u32;
|
||||
let proxy_info = ProxyInfo {
|
||||
id: format!("proxy_{i}"),
|
||||
local_url: format!("http://127.0.0.1:{}", 8000 + i),
|
||||
upstream_host: "127.0.0.1".to_string(),
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
{
|
||||
let mut active_proxies = pm.active_proxies.lock().unwrap();
|
||||
active_proxies.insert(browser_pid, proxy_info);
|
||||
}
|
||||
|
||||
// Read proxy
|
||||
let settings = pm.get_proxy_settings(browser_pid);
|
||||
assert!(settings.is_some());
|
||||
|
||||
browser_pid
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
let results: Vec<u32> = futures_util::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
// Verify all browser PIDs were processed
|
||||
assert_eq!(results.len(), 10);
|
||||
for (i, &browser_pid) in results.iter().enumerate() {
|
||||
assert_eq!(browser_pid, (1000 + i) as u32);
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test that actually builds and uses nodecar binary
|
||||
#[tokio::test]
|
||||
async fn test_proxy_integration_with_real_nodecar() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// This test requires nodecar to be built and available
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Start a mock upstream HTTP server
|
||||
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let upstream_addr = upstream_listener.local_addr()?;
|
||||
|
||||
// Spawn upstream server
|
||||
let server_handle = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = upstream_listener.accept().await {
|
||||
let io = TokioIo::new(stream);
|
||||
tokio::task::spawn(async move {
|
||||
let _ = http1::Builder::new()
|
||||
.serve_connection(
|
||||
io,
|
||||
service_fn(|_req| async {
|
||||
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK"))))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for server to start
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Test nodecar proxy start command directly (using the binary itself, not node)
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg(upstream_addr.ip().to_string())
|
||||
.arg("--proxy-port")
|
||||
.arg(upstream_addr.port().to_string())
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
// Set a timeout for the command
|
||||
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify proxy configuration
|
||||
assert!(config["id"].is_string());
|
||||
assert!(config["localPort"].is_number());
|
||||
assert!(config["localUrl"].is_string());
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
let local_port = config["localPort"].as_u64().unwrap();
|
||||
|
||||
// Wait for proxy worker to start
|
||||
println!("Waiting for proxy worker to start...");
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Test that the local port is listening
|
||||
let mut port_test = Command::new("nc");
|
||||
port_test
|
||||
.arg("-z")
|
||||
.arg("127.0.0.1")
|
||||
.arg(local_port.to_string());
|
||||
|
||||
let port_output = port_test.output()?;
|
||||
if port_output.status.success() {
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
} else {
|
||||
println!("Warning: Proxy port {local_port} is not listening");
|
||||
}
|
||||
|
||||
// Test stopping the proxy
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
|
||||
let stop_output =
|
||||
tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??;
|
||||
|
||||
assert!(stop_output.status.success());
|
||||
|
||||
println!("Integration test passed: nodecar proxy start/stop works correctly");
|
||||
} else {
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
eprintln!("Nodecar failed: {stderr}");
|
||||
return Err(format!("Nodecar command failed: {stderr}").into());
|
||||
}
|
||||
|
||||
// Clean up server
|
||||
server_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates the command line arguments are constructed correctly
|
||||
#[test]
|
||||
fn test_proxy_command_construction() {
|
||||
let proxy_settings = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "proxy.example.com".to_string(),
|
||||
port: 8080,
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pass".to_string()),
|
||||
};
|
||||
|
||||
// Test command arguments match expected format
|
||||
let _expected_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"proxy.example.com",
|
||||
"--proxy-port",
|
||||
"8080",
|
||||
"--type",
|
||||
"http",
|
||||
"--username",
|
||||
"user",
|
||||
"--password",
|
||||
"pass",
|
||||
];
|
||||
|
||||
// This test verifies the argument structure without actually running the command
|
||||
assert_eq!(proxy_settings.host, "proxy.example.com");
|
||||
assert_eq!(proxy_settings.port, 8080);
|
||||
assert_eq!(proxy_settings.proxy_type, "http");
|
||||
assert_eq!(proxy_settings.username.as_ref().unwrap(), "user");
|
||||
assert_eq!(proxy_settings.password.as_ref().unwrap(), "pass");
|
||||
}
|
||||
|
||||
// Test the CLI detachment specifically - ensure the CLI exits properly
|
||||
#[tokio::test]
|
||||
async fn test_cli_exits_after_proxy_start() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test that the CLI exits quickly with a mock upstream
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("httpbin.org")
|
||||
.arg("--proxy-port")
|
||||
.arg("80")
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(3), async { cmd.output() }).await;
|
||||
|
||||
match output {
|
||||
Ok(Ok(cmd_output)) => {
|
||||
let execution_time = start_time.elapsed();
|
||||
println!("CLI completed in {execution_time:?}");
|
||||
|
||||
// Should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(3),
|
||||
"CLI took too long ({execution_time:?}), should exit immediately after starting worker"
|
||||
);
|
||||
|
||||
if cmd_output.status.success() {
|
||||
let stdout = String::from_utf8(cmd_output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Clean up - try to stop the proxy
|
||||
if let Some(proxy_id) = config["id"].as_str() {
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
}
|
||||
}
|
||||
|
||||
println!("CLI detachment test passed - CLI exited in {execution_time:?}");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(format!("Command execution failed: {e}").into());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("CLI command timed out - this indicates improper detachment".into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates proper CLI detachment behavior
|
||||
#[tokio::test]
|
||||
async fn test_cli_detachment_behavior() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test that the CLI command exits quickly even with a real upstream
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("httpbin.org") // Use a known good endpoint
|
||||
.arg("--proxy-port")
|
||||
.arg("80")
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
let execution_time = start_time.elapsed();
|
||||
|
||||
// Command should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(5),
|
||||
"CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment"
|
||||
);
|
||||
|
||||
println!("CLI detachment test: command completed in {execution_time:?}");
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
|
||||
// Clean up
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
|
||||
println!("CLI detachment test passed");
|
||||
} else {
|
||||
// Even if the upstream fails, the CLI should still exit quickly
|
||||
println!("CLI command failed but exited quickly as expected");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates URL encoding for special characters in credentials
|
||||
#[tokio::test]
|
||||
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test with credentials that include special characters
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("test.example.com")
|
||||
.arg("--proxy-port")
|
||||
.arg("8080")
|
||||
.arg("--type")
|
||||
.arg("http")
|
||||
.arg("--username")
|
||||
.arg("user@domain.com") // Contains @ symbol
|
||||
.arg("--password")
|
||||
.arg("pass word!"); // Contains space and special character
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let upstream_url = config["upstreamUrl"].as_str().unwrap();
|
||||
|
||||
println!("Generated upstream URL: {upstream_url}");
|
||||
|
||||
// Verify that special characters are properly encoded
|
||||
assert!(upstream_url.contains("user%40domain.com"));
|
||||
// The password may be encoded as "pass%20word!" or "pass%20word%21" depending on implementation
|
||||
assert!(upstream_url.contains("pass%20word"));
|
||||
|
||||
println!("URL encoding test passed - special characters handled correctly");
|
||||
|
||||
// Clean up
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
} else {
|
||||
// This test might fail if the upstream doesn't exist, but we mainly care about URL construction
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
println!("Command failed (expected for non-existent upstream):");
|
||||
println!("Stdout: {stdout}");
|
||||
println!("Stderr: {stderr}");
|
||||
|
||||
// The important thing is that the command completed quickly
|
||||
println!("URL encoding test completed - credentials should be properly encoded");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -61,8 +61,9 @@
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"schemes": ["http", "https"],
|
||||
"domains": []
|
||||
"desktop": {
|
||||
"schemes": ["http", "https"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-1
@@ -3,6 +3,7 @@
|
||||
import { ChangeVersionDialog } from "@/components/change-version-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
||||
@@ -21,11 +22,14 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, ProxySettings } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
@@ -58,6 +62,11 @@ export default function Home() {
|
||||
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||
const [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
// Simple profiles loader without updates check (for use as callback)
|
||||
const loadProfiles = useCallback(async () => {
|
||||
@@ -94,6 +103,18 @@ export default function Home() {
|
||||
|
||||
useAppUpdateNotifications();
|
||||
|
||||
// For some reason, app.deep_link().get_current() is not working properly
|
||||
const checkCurrentUrl = useCallback(async () => {
|
||||
try {
|
||||
const currentUrl = await getCurrent();
|
||||
if (currentUrl && currentUrl.length > 0) {
|
||||
void handleUrlOpen(currentUrl[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check current URL:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
|
||||
@@ -105,6 +126,7 @@ export default function Home() {
|
||||
|
||||
// Check for startup URLs (when app was launched as default browser)
|
||||
void checkStartupUrls();
|
||||
void checkCurrentUrl();
|
||||
|
||||
// Set up periodic update checks (every 30 minutes)
|
||||
const updateInterval = setInterval(
|
||||
@@ -117,7 +139,14 @@ export default function Home() {
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
|
||||
}, [loadProfilesWithUpdateCheck, checkForUpdates, checkCurrentUrl]);
|
||||
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
void checkAllPermissions();
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
const checkStartupPrompt = async () => {
|
||||
// Only check once during app startup to prevent reopening after dismissing notifications
|
||||
@@ -137,6 +166,42 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const checkAllPermissions = async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any permissions are not granted - prioritize missing permissions
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check permissions:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNextPermission = () => {
|
||||
try {
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStartupUrls = async () => {
|
||||
try {
|
||||
const hasStartupUrl = await invoke<boolean>(
|
||||
@@ -533,6 +598,15 @@ export default function Home() {
|
||||
runningProfiles={runningProfiles}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PermissionDialog
|
||||
isOpen={permissionDialogOpen}
|
||||
onClose={() => {
|
||||
setPermissionDialogOpen(false);
|
||||
}}
|
||||
permissionType={currentPermissionType}
|
||||
onPermissionGranted={checkNextPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export function CreateProfileDialog({
|
||||
const [proxyType, setProxyType] = useState("http");
|
||||
const [proxyHost, setProxyHost] = useState("");
|
||||
const [proxyPort, setProxyPort] = useState(8080);
|
||||
const [proxyUsername, setProxyUsername] = useState("");
|
||||
const [proxyPassword, setProxyPassword] = useState("");
|
||||
|
||||
const {
|
||||
availableVersions,
|
||||
@@ -194,6 +196,8 @@ export function CreateProfileDialog({
|
||||
proxy_type: proxyType,
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
username: proxyUsername || undefined,
|
||||
password: proxyPassword || undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -210,6 +214,8 @@ export function CreateProfileDialog({
|
||||
setProxyEnabled(false);
|
||||
setProxyHost("");
|
||||
setProxyPort(8080);
|
||||
setProxyUsername("");
|
||||
setProxyPassword("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create profile:", error);
|
||||
@@ -231,12 +237,12 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid overflow-y-scroll flex-1 gap-6 py-4 min-h-0">
|
||||
{/* Profile Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
@@ -293,7 +299,7 @@ export function CreateProfileDialog({
|
||||
disabled={true}
|
||||
className="opacity-50"
|
||||
>
|
||||
{displayName} (Not supported on this platform)
|
||||
{displayName} (Not supported)
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -414,6 +420,31 @@ export function CreateProfileDialog({
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">Username (optional)</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={proxyUsername}
|
||||
onChange={(e) => {
|
||||
setProxyUsername(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">Password (optional)</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={proxyPassword}
|
||||
onChange={(e) => {
|
||||
setProxyPassword(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -116,31 +116,31 @@ type ToastProps =
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="h-4 w-4 text-red-500 flex-shrink-0" />;
|
||||
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
|
||||
);
|
||||
}
|
||||
return <LuDownload className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,10 +151,10 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
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-3 shadow-lg">
|
||||
<div className="flex items-start p-3 w-full bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white leading-tight">
|
||||
<p className="text-sm font-medium leading-tight text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -165,7 +165,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 min-w-0 flex-1">
|
||||
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
@@ -195,7 +195,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0 w-8 text-right">
|
||||
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
@@ -220,7 +220,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
|
||||
<p className="mt-1 text-xs leading-tight text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -235,7 +235,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Verifying installation...
|
||||
Verifying browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
|
||||
@@ -458,7 +458,7 @@ export function ImportProfileDialog({
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
Import Detected Profile
|
||||
Import Profile
|
||||
</LoadingButton>
|
||||
) : (
|
||||
<LoadingButton
|
||||
@@ -472,7 +472,7 @@ export function ImportProfileDialog({
|
||||
!manualProfileName.trim()
|
||||
}
|
||||
>
|
||||
Import Manual Profile
|
||||
Import Profile
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
|
||||
interface PermissionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
permissionType: PermissionType;
|
||||
onPermissionGranted?: () => void;
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
permissionType,
|
||||
onPermissionGranted,
|
||||
}: PermissionDialogProps) {
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
|
||||
// Check if we're on macOS and close dialog if not
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
|
||||
// If not macOS, close the dialog as permissions aren't needed
|
||||
if (!isMac) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Get current permission status
|
||||
const isCurrentPermissionGranted =
|
||||
permissionType === "microphone"
|
||||
? isMicrophoneAccessGranted
|
||||
: isCameraAccessGranted;
|
||||
|
||||
// Auto-close dialog when permission is granted
|
||||
useEffect(() => {
|
||||
if (isCurrentPermissionGranted && isOpen) {
|
||||
onPermissionGranted?.();
|
||||
}
|
||||
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]);
|
||||
|
||||
const getPermissionIcon = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return <BsMic className="w-8 h-8" />;
|
||||
case "camera":
|
||||
return <BsCamera className="w-8 h-8" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionTitle = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone Access Required";
|
||||
case "camera":
|
||||
return "Camera Access Required";
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
|
||||
case "camera":
|
||||
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionTitle(permissionType).replace(
|
||||
" Required",
|
||||
"",
|
||||
)} permission requested`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
showErrorToast("Failed to request permission");
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if not macOS
|
||||
if (!isMacOS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader className="text-center">
|
||||
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
|
||||
{getPermissionIcon(permissionType)}
|
||||
</div>
|
||||
<DialogTitle className="text-xl">
|
||||
{getPermissionTitle(permissionType)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
{getPermissionDescription(permissionType)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
✅ Permission granted! Browsers launched from Donut Browser can
|
||||
now access your {permissionType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
⚠️ Permission not granted. Click the button below to request
|
||||
access to your {permissionType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
</Button>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<LoadingButton
|
||||
isLoading={isRequesting}
|
||||
onClick={() => {
|
||||
handleRequestPermission().catch(console.error);
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
Grant Access
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export function ProfilesDataTable({
|
||||
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -206,21 +206,34 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Profile
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return profile.name.length > 15 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate">{profile.name.slice(0, 15)}...</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{profile.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
profile.name
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "browser",
|
||||
@@ -232,15 +245,15 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Browser
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
@@ -249,8 +262,8 @@ export function ProfilesDataTable({
|
||||
const browser: string = row.getValue("browser");
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
<div className="flex gap-2 items-center">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{getBrowserDisplayName(browser)}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -276,15 +289,15 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Status
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
@@ -335,9 +348,9 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
{hasProxy && (
|
||||
<CiCircleCheck className="h-4 w-4 text-green-500" />
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{hasProxy ? profile.proxy?.proxy_type : "Disabled"}
|
||||
@@ -363,16 +376,16 @@ export function ProfilesDataTable({
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isClient && isUpdating(profile.browser);
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="h-4 w-4" />
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -511,7 +524,7 @@ export function ProfilesDataTable({
|
||||
<DialogTitle>Rename Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
|
||||
@@ -138,10 +138,6 @@ export function ProfileSelectorDialog({
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
if (profile.browser === "tor-browser") {
|
||||
const runningTorProfiles = profiles.filter(
|
||||
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If another TOR profile is running, this one is not available
|
||||
return "Only 1 instance can run at a time";
|
||||
}
|
||||
@@ -195,9 +191,6 @@ export function ProfileSelectorDialog({
|
||||
};
|
||||
|
||||
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
|
||||
const isSelectedProfileRunning = selectedProfile
|
||||
? runningProfiles.has(selectedProfile)
|
||||
: false;
|
||||
|
||||
// Check if the selected profile can be used for opening links
|
||||
const canOpenWithSelectedProfile = () => {
|
||||
@@ -225,19 +218,19 @@ export function ProfileSelectorDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleCopyUrl()}
|
||||
className="flex items-center gap-2"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuCopy className="h-3 w-3" />
|
||||
<LuCopy className="w-3 h-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded text-sm break-all">
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,14 +283,14 @@ export function ProfileSelectorDialog({
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 py-1 px-2 rounded-lg hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<IconComponent className="w-4 h-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ interface ProxySettings {
|
||||
proxy_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface ProxySettingsDialogProps {
|
||||
@@ -52,6 +54,8 @@ export function ProxySettingsDialog({
|
||||
proxy_type: initialSettings?.proxy_type ?? "http",
|
||||
host: initialSettings?.host ?? "",
|
||||
port: initialSettings?.port ?? 8080,
|
||||
username: initialSettings?.username ?? "",
|
||||
password: initialSettings?.password ?? "",
|
||||
});
|
||||
|
||||
const [initialSettingsState, setInitialSettingsState] =
|
||||
@@ -60,6 +64,8 @@ export function ProxySettingsDialog({
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,6 +75,8 @@ export function ProxySettingsDialog({
|
||||
proxy_type: initialSettings.proxy_type,
|
||||
host: initialSettings.host,
|
||||
port: initialSettings.port,
|
||||
username: initialSettings.username ?? "",
|
||||
password: initialSettings.password ?? "",
|
||||
};
|
||||
setSettings(newSettings);
|
||||
setInitialSettingsState(newSettings);
|
||||
@@ -78,6 +86,8 @@ export function ProxySettingsDialog({
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 80,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
setSettings(defaultSettings);
|
||||
setInitialSettingsState(defaultSettings);
|
||||
@@ -94,7 +104,9 @@ export function ProxySettingsDialog({
|
||||
settings.enabled !== initialSettingsState.enabled ||
|
||||
settings.proxy_type !== initialSettingsState.proxy_type ||
|
||||
settings.host !== initialSettingsState.host ||
|
||||
settings.port !== initialSettingsState.port
|
||||
settings.port !== initialSettingsState.port ||
|
||||
settings.username !== initialSettingsState.username ||
|
||||
settings.password !== initialSettingsState.password
|
||||
);
|
||||
};
|
||||
|
||||
@@ -214,6 +226,31 @@ export function ProxySettingsDialog({
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username (optional)</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={settings.username}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, username: e.target.value });
|
||||
}}
|
||||
placeholder="Proxy username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password (optional)</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={settings.password}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, password: e.target.value });
|
||||
}}
|
||||
placeholder="Proxy password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,10 +19,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
@@ -32,6 +35,12 @@ interface AppSettings {
|
||||
auto_delete_unused_binaries: boolean;
|
||||
}
|
||||
|
||||
interface PermissionInfo {
|
||||
permission_type: PermissionType;
|
||||
isGranted: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -57,18 +66,46 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
||||
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
|
||||
const getPermissionDescription = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Access to microphone for browser applications";
|
||||
case "camera":
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSettings();
|
||||
void checkDefaultBrowserStatus();
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
|
||||
if (isMac) {
|
||||
loadPermissions().catch(console.error);
|
||||
}
|
||||
|
||||
// Set up interval to check default browser status
|
||||
const intervalId = setInterval(() => {
|
||||
void checkDefaultBrowserStatus();
|
||||
}, 500); // Check every 2 seconds
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
@@ -77,6 +114,32 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
if (isMacOS) {
|
||||
const permissionList: PermissionInfo[] = [
|
||||
{
|
||||
permission_type: "microphone",
|
||||
isGranted: isMicrophoneAccessGranted,
|
||||
description: getPermissionDescription("microphone"),
|
||||
},
|
||||
{
|
||||
permission_type: "camera",
|
||||
isGranted: isCameraAccessGranted,
|
||||
description: getPermissionDescription("camera"),
|
||||
},
|
||||
];
|
||||
setPermissions(permissionList);
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
}, [
|
||||
isMacOS,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
getPermissionDescription,
|
||||
]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -90,6 +153,36 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
if (!isMacOS) {
|
||||
// On non-macOS platforms, don't show permissions
|
||||
setPermissions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionList: PermissionInfo[] = [
|
||||
{
|
||||
permission_type: "microphone",
|
||||
isGranted: isMicrophoneAccessGranted,
|
||||
description: getPermissionDescription("microphone"),
|
||||
},
|
||||
{
|
||||
permission_type: "camera",
|
||||
isGranted: isCameraAccessGranted,
|
||||
description: getPermissionDescription("camera"),
|
||||
},
|
||||
];
|
||||
|
||||
setPermissions(permissionList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load permissions:", error);
|
||||
} finally {
|
||||
setIsLoadingPermissions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkDefaultBrowserStatus = async () => {
|
||||
try {
|
||||
const isDefault = await invoke<boolean>("is_default_browser");
|
||||
@@ -127,6 +220,49 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestPermission = async (permissionType: PermissionType) => {
|
||||
setRequestingPermission(permissionType);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionDisplayName(permissionType)} access requested`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
} finally {
|
||||
setRequestingPermission(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionIcon = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return <BsMic className="h-4 w-4" />;
|
||||
case "camera":
|
||||
return <BsCamera className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDisplayName = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone";
|
||||
case "camera":
|
||||
return "Camera";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
Granted
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Not Granted</Badge>;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@@ -204,7 +340,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isSettingDefault}
|
||||
onClick={() => {
|
||||
void handleSetDefaultBrowser();
|
||||
handleSetDefaultBrowser().catch(console.error);
|
||||
}}
|
||||
disabled={isDefaultBrowser}
|
||||
variant={isDefaultBrowser ? "outline" : "default"}
|
||||
@@ -284,6 +420,69 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions Section - Only show on macOS */}
|
||||
{isMacOS && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
System Permissions
|
||||
</Label>
|
||||
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.permission_type}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getPermissionIcon(permission.permission_type)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{getPermissionDisplayName(
|
||||
permission.permission_type,
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusBadge(permission.isGranted)}
|
||||
{!permission.isGranted && (
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={
|
||||
requestingPermission ===
|
||||
permission.permission_type
|
||||
}
|
||||
onClick={() => {
|
||||
handleRequestPermission(
|
||||
permission.permission_type,
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These permissions allow browsers launched from Donut Browser to
|
||||
access system resources. Each website will still ask for your
|
||||
permission individually.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
@@ -291,7 +490,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
void handleClearCache();
|
||||
handleClearCache().catch(console.error);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
@@ -314,7 +513,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
handleSave().catch(console.error);
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
|
||||
@@ -103,7 +103,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
const currentSystemTheme = await getNativeSystemTheme();
|
||||
// Force re-evaluation by toggling the theme
|
||||
const html = document.documentElement;
|
||||
const currentClass = html.className;
|
||||
|
||||
// Apply the system theme class
|
||||
if (currentSystemTheme === "dark") {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import React from "react";
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuDownload } from "react-icons/lu";
|
||||
|
||||
interface UpdateNotification {
|
||||
id: string;
|
||||
|
||||
@@ -24,11 +24,11 @@ import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
assets: {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
hash?: string;
|
||||
}>;
|
||||
}[];
|
||||
published_at: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function WindowDragArea() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 left-0 z-50 h-8 cursor-move"
|
||||
className="fixed top-0 right-0 left-0 h-10 z-9999"
|
||||
style={{
|
||||
// Ensure it's above all other content
|
||||
zIndex: 9999,
|
||||
|
||||
@@ -13,11 +13,11 @@ import { toast } from "sonner";
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
assets: {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
hash?: string;
|
||||
}>;
|
||||
}[];
|
||||
published_at: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
@@ -54,22 +54,6 @@ interface VersionUpdateProgress {
|
||||
status: string;
|
||||
}
|
||||
|
||||
const isAlphaVersion = (version: string): boolean => {
|
||||
// Check for common alpha/beta/dev indicators
|
||||
const lowerVersion = version.toLowerCase();
|
||||
return (
|
||||
lowerVersion.includes("a") ||
|
||||
lowerVersion.includes("b") ||
|
||||
lowerVersion.includes("alpha") ||
|
||||
lowerVersion.includes("beta") ||
|
||||
lowerVersion.includes("dev") ||
|
||||
lowerVersion.includes("rc") ||
|
||||
lowerVersion.includes("pre") ||
|
||||
// Check for patterns like "139.0b1" or "140.0a1"
|
||||
/\d+\.\d+[ab]\d+/.test(lowerVersion)
|
||||
);
|
||||
};
|
||||
|
||||
export function useBrowserDownload() {
|
||||
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Platform-specific imports
|
||||
let macOSPermissions:
|
||||
| typeof import("tauri-plugin-macos-permissions-api")
|
||||
| null = null;
|
||||
|
||||
// Dynamically import macOS permissions only when needed
|
||||
const loadMacOSPermissions = async () => {
|
||||
if (macOSPermissions) return macOSPermissions;
|
||||
|
||||
try {
|
||||
macOSPermissions = await import("tauri-plugin-macos-permissions-api");
|
||||
return macOSPermissions;
|
||||
} catch (error) {
|
||||
console.warn("Failed to load macOS permissions API:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type PermissionType = "microphone" | "camera";
|
||||
|
||||
export interface UsePermissionsReturn {
|
||||
requestPermission: (type: PermissionType) => Promise<void>;
|
||||
isMicrophoneAccessGranted: boolean;
|
||||
isCameraAccessGranted: boolean;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
export function usePermissions(): UsePermissionsReturn {
|
||||
const [isMicrophoneAccessGranted, setIsMicrophoneAccessGranted] =
|
||||
useState(false);
|
||||
const [isCameraAccessGranted, setIsCameraAccessGranted] = useState(false);
|
||||
const [currentPlatform, setCurrentPlatform] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Check permissions status
|
||||
const checkPermissions = useCallback(async () => {
|
||||
if (!currentPlatform) return;
|
||||
|
||||
if (currentPlatform !== "macos") {
|
||||
// Windows/Linux - assume permissions are granted
|
||||
setIsMicrophoneAccessGranted(true);
|
||||
setIsCameraAccessGranted(true);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS - use the permissions API
|
||||
try {
|
||||
const permissions = await loadMacOSPermissions();
|
||||
if (permissions) {
|
||||
const [micGranted, camGranted] = await Promise.all([
|
||||
permissions.checkMicrophonePermission(),
|
||||
permissions.checkCameraPermission(),
|
||||
]);
|
||||
|
||||
setIsMicrophoneAccessGranted(micGranted);
|
||||
setIsCameraAccessGranted(camGranted);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check permissions on macOS:", error);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [currentPlatform]);
|
||||
|
||||
// Request permission
|
||||
const requestPermission = useCallback(
|
||||
async (type: PermissionType): Promise<void> => {
|
||||
if (!currentPlatform || currentPlatform !== "macos") return;
|
||||
|
||||
// macOS - use the permissions API
|
||||
try {
|
||||
const permissions = await loadMacOSPermissions();
|
||||
if (!permissions) return;
|
||||
|
||||
if (type === "microphone") {
|
||||
await permissions.requestMicrophonePermission();
|
||||
|
||||
// Poll for permission status change
|
||||
const pollMicPermission = async () => {
|
||||
const granted = await permissions.checkMicrophonePermission();
|
||||
setIsMicrophoneAccessGranted(granted);
|
||||
|
||||
if (!granted) {
|
||||
setTimeout(() => {
|
||||
void pollMicPermission();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
await pollMicPermission();
|
||||
}
|
||||
|
||||
if (type === "camera") {
|
||||
await permissions.requestCameraPermission();
|
||||
|
||||
// Poll for permission status change
|
||||
const pollCamPermission = async () => {
|
||||
const granted = await permissions.checkCameraPermission();
|
||||
setIsCameraAccessGranted(granted);
|
||||
|
||||
if (!granted) {
|
||||
setTimeout(() => {
|
||||
void pollCamPermission();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
await pollCamPermission();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to request ${type} permission on macOS:`, error);
|
||||
}
|
||||
},
|
||||
[currentPlatform],
|
||||
);
|
||||
|
||||
// Initialize platform detection and start interval checking
|
||||
useEffect(() => {
|
||||
const initializePlatform = async () => {
|
||||
try {
|
||||
// Detect platform - on macOS we need permissions, on others we don't
|
||||
const userAgent = navigator.userAgent;
|
||||
let platformName = "unknown";
|
||||
|
||||
if (userAgent.includes("Mac")) {
|
||||
platformName = "macos";
|
||||
} else if (userAgent.includes("Win")) {
|
||||
platformName = "windows";
|
||||
} else if (userAgent.includes("Linux")) {
|
||||
platformName = "linux";
|
||||
}
|
||||
|
||||
setCurrentPlatform(platformName);
|
||||
} catch (error) {
|
||||
console.error("Failed to detect platform:", error);
|
||||
// Fallback - assume non-macOS
|
||||
setCurrentPlatform("unknown");
|
||||
}
|
||||
};
|
||||
|
||||
initializePlatform().catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Set up interval checking when platform is determined
|
||||
useEffect(() => {
|
||||
if (!currentPlatform) return;
|
||||
|
||||
// Initial check
|
||||
void checkPermissions();
|
||||
|
||||
// Set up 500ms interval for checking permissions
|
||||
intervalRef.current = setInterval(() => {
|
||||
void checkPermissions();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentPlatform, checkPermissions]);
|
||||
|
||||
return {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showLoadingToast,
|
||||
showVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import { showLoadingToast, showVersionUpdateToast } from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
@@ -134,7 +134,7 @@ export function showToast(props: ToastProps & { id?: string }) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sonnerToast.custom((id) => React.createElement(UnifiedToast, props), {
|
||||
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
|
||||
id: toastId,
|
||||
duration,
|
||||
style: {
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface ProxySettings {
|
||||
proxy_type: string; // "http", "https", "socks4", or "socks5"
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface TableSortingSettings {
|
||||
@@ -40,3 +42,17 @@ export interface AppVersionInfo {
|
||||
version: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
|
||||
export type PermissionType = "microphone" | "camera" | "location";
|
||||
|
||||
export type PermissionStatus =
|
||||
| "granted"
|
||||
| "denied"
|
||||
| "not_determined"
|
||||
| "restricted";
|
||||
|
||||
export interface PermissionInfo {
|
||||
permission_type: PermissionType;
|
||||
status: PermissionStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user