Compare commits

...
Author SHA1 Message Date
zhom 6c76dc1a34 chore: version bump 2026-03-21 10:41:40 +04:00
zhom e45f4a792f fix: remove browser cleanup from profile sync 2026-03-21 10:40:52 +04:00
zhom 0860a3b6e0 deps: bump rustls-webpki to fix RUSTSEC-2026-0049 2026-03-21 04:48:57 +04:00
zhom 0222c7e904 fix: profile sync metadata merge and delta subscription matching 2026-03-21 04:04:48 +04:00
zhom 786acc4356 fix: mcp server spec compliance and claude desktop setup 2026-03-21 02:01:49 +04:00
zhom a813358c49 refactor: usage status 2026-03-21 02:01:49 +04:00
zhom a3fd056d6e refactor: spawn detached proxy process for ip checK 2026-03-21 02:01:49 +04:00
zhom 806e2497c0 refactor: download logging 2026-03-21 02:01:49 +04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c742964d86 deps(rust)(deps): bump tar from 0.4.44 to 0.4.45 in /src-tauri (#241)
Bumps [tar](https://github.com/alexcrichton/tar-rs) from 0.4.44 to 0.4.45.
- [Commits](https://github.com/alexcrichton/tar-rs/compare/0.4.44...0.4.45)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 0.4.45
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 18:57:02 +00:00
zhom 57e17b46e9 chore: version bump 2026-03-20 02:45:11 +04:00
zhom 116a54942d refactor: networking 2026-03-20 02:45:11 +04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8936816613 deps(deps): bump next from 16.1.6 to 16.1.7 (#239)
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.1.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.1.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 23:23:58 +00:00
zhom db05ffdef6 chore: version bump 2026-03-17 13:18:56 +04:00
zhom 96614a3f33 refactor: better tombstone handling 2026-03-17 13:15:48 +04:00
zhom 222a8b89f5 chore: version bump 2026-03-16 23:21:17 +04:00
zhom 69e68a7331 chore: don't try to detect magic bits fro dmg 2026-03-16 23:20:34 +04:00
zhom 5e6faf4e2c chore: version bump 2026-03-16 21:38:39 +04:00
zhom cf1e49c761 refactor: more robust output parsing 2026-03-16 21:38:03 +04:00
35 changed files with 876 additions and 514 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.17.1",
"version": "0.17.6",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -60,7 +60,7 @@
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"motion": "^12.36.0",
"next": "^16.1.6",
"next": "^16.1.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
+51 -51
View File
@@ -93,8 +93,8 @@ importers:
specifier: ^12.36.0
version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next:
specifier: ^16.1.6
version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: ^16.1.7
version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1362,57 +1362,57 @@ packages:
'@nestjs/platform-express':
optional: true
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/env@16.1.7':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
'@next/swc-darwin-arm64@16.1.6':
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
'@next/swc-darwin-arm64@16.1.7':
resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.6':
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
'@next/swc-darwin-x64@16.1.7':
resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.6':
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
'@next/swc-linux-arm64-gnu@16.1.7':
resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
'@next/swc-linux-arm64-musl@16.1.7':
resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
'@next/swc-linux-x64-gnu@16.1.7':
resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
'@next/swc-linux-x64-musl@16.1.7':
resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
'@next/swc-win32-arm64-msvc@16.1.7':
resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.6':
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
'@next/swc-win32-x64-msvc@16.1.7':
resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3265,8 +3265,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.7:
resolution: {integrity: sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==}
baseline-browser-mapping@2.10.8:
resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3343,8 +3343,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001778:
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -4590,8 +4590,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
next@16.1.7:
resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -6982,30 +6982,30 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@next/env@16.1.6': {}
'@next/env@16.1.7': {}
'@next/swc-darwin-arm64@16.1.6':
'@next/swc-darwin-arm64@16.1.7':
optional: true
'@next/swc-darwin-x64@16.1.6':
'@next/swc-darwin-x64@16.1.7':
optional: true
'@next/swc-linux-arm64-gnu@16.1.6':
'@next/swc-linux-arm64-gnu@16.1.7':
optional: true
'@next/swc-linux-arm64-musl@16.1.6':
'@next/swc-linux-arm64-musl@16.1.7':
optional: true
'@next/swc-linux-x64-gnu@16.1.6':
'@next/swc-linux-x64-gnu@16.1.7':
optional: true
'@next/swc-linux-x64-musl@16.1.6':
'@next/swc-linux-x64-musl@16.1.7':
optional: true
'@next/swc-win32-arm64-msvc@16.1.6':
'@next/swc-win32-arm64-msvc@16.1.7':
optional: true
'@next/swc-win32-x64-msvc@16.1.6':
'@next/swc-win32-x64-msvc@16.1.7':
optional: true
'@noble/hashes@1.8.0': {}
@@ -8916,7 +8916,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.7: {}
baseline-browser-mapping@2.10.8: {}
bl@4.1.0:
dependencies:
@@ -8959,8 +8959,8 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.7
caniuse-lite: 1.0.30001778
baseline-browser-mapping: 2.10.8
caniuse-lite: 1.0.30001780
electron-to-chromium: 1.5.313
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -9004,7 +9004,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001778: {}
caniuse-lite@1.0.30001780: {}
chalk@4.1.2:
dependencies:
@@ -10351,25 +10351,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@next/env': 16.1.7
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.7
caniuse-lite: 1.0.30001778
baseline-browser-mapping: 2.10.8
caniuse-lite: 1.0.30001780
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@next/swc-darwin-arm64': 16.1.7
'@next/swc-darwin-x64': 16.1.7
'@next/swc-linux-arm64-gnu': 16.1.7
'@next/swc-linux-arm64-musl': 16.1.7
'@next/swc-linux-x64-gnu': 16.1.7
'@next/swc-linux-x64-musl': 16.1.7
'@next/swc-win32-arm64-msvc': 16.1.7
'@next/swc-win32-x64-msvc': 16.1.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
+17 -17
View File
@@ -182,7 +182,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -193,7 +193,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1642,7 +1642,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1717,7 +1717,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.17.1"
version = "0.17.6"
dependencies = [
"aes",
"aes-gcm",
@@ -1979,7 +1979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4346,7 +4346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5608,7 +5608,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5635,9 +5635,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
@@ -6223,7 +6223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6548,9 +6548,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -6957,10 +6957,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7482,7 +7482,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8080,7 +8080,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8600,7 +8600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.17.1"
version = "0.17.6"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+3 -1
View File
@@ -704,7 +704,8 @@ impl AppAutoUpdater {
let total_size = response.content_length().unwrap_or(0);
log::info!("Silent download size: {} bytes", total_size);
let mut file = fs::File::create(&file_path)?;
let raw_file = fs::File::create(&file_path)?;
let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -712,6 +713,7 @@ impl AppAutoUpdater {
let chunk = chunk?;
file.write_all(&chunk)?;
}
std::io::Write::flush(&mut file)?;
log::info!("Silent download completed: {}", file_path.display());
Ok(file_path)
+8
View File
@@ -145,6 +145,14 @@ impl AutoUpdater {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
// Skip if this browser-version pair is already being downloaded
if crate::downloader::is_downloading(&browser, &new_version) {
log::info!(
"Browser {browser} {new_version} is already being downloaded, skipping duplicate"
);
return;
}
if registry.is_browser_downloaded(&browser, &new_version) {
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
+9 -18
View File
@@ -2157,14 +2157,11 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
@@ -2196,14 +2193,11 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
@@ -2516,14 +2510,11 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
+8 -10
View File
@@ -591,8 +591,8 @@ impl CloudAuthManager {
// Clear wayfern token
self.clear_wayfern_token().await;
// Disconnect team lock manager
crate::team_lock::TEAM_LOCK.disconnect().await;
// Disconnect profile lock manager
crate::team_lock::PROFILE_LOCK.disconnect().await;
// Try to call the logout API (best-effort)
if let Ok(Some(access_token)) = Self::load_access_token() {
@@ -1070,10 +1070,10 @@ impl CloudAuthManager {
log::debug!("Failed to refresh cloud profile: {e}");
}
// Reconnect team lock manager if needed
// Reconnect profile lock manager if needed
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
if let Some(tid) = &auth_state.user.team_id {
crate::team_lock::TEAM_LOCK.connect(tid).await;
if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await {
crate::team_lock::PROFILE_LOCK.connect().await;
}
}
@@ -1137,11 +1137,9 @@ pub async fn cloud_verify_otp(
// Sync cloud proxy after login
CLOUD_AUTH.sync_cloud_proxy().await;
// Connect team lock manager if on a team plan
if state.user.team_id.is_some() {
if let Some(tid) = &state.user.team_id {
crate::team_lock::TEAM_LOCK.connect(tid).await;
}
// Connect profile lock manager for paid users
if state.user.plan != "free" {
crate::team_lock::PROFILE_LOCK.connect().await;
}
let _ = crate::events::emit_empty("cloud-auth-changed");
+14 -2
View File
@@ -513,6 +513,11 @@ impl DownloadedBrowsersRegistry {
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Never remove a directory if a download is in progress for this browser/version
if crate::downloader::is_downloading(browser, version) {
return Ok(());
}
let binaries_dir = crate::app_dirs::binaries_dir();
let version_dir = binaries_dir.join(browser).join(version);
@@ -593,6 +598,12 @@ impl DownloadedBrowsersRegistry {
continue;
}
// Skip if a download is in progress for this browser/version
if crate::downloader::is_downloading(browser_name, version_name) {
has_non_empty_versions = true;
continue;
}
// Check if version directory is empty
match fs::read_dir(&version_path) {
Ok(mut entries) => {
@@ -1237,12 +1248,13 @@ pub async fn ensure_active_browsers_downloaded(
// Check if any version is already downloaded
let existing = registry.get_downloaded_versions(browser);
if !existing.is_empty() {
log::debug!(
"Skipping {browser}: already have {} version(s) downloaded",
log::info!(
"ensure_active: Skipping {browser}: already have {} version(s) downloaded",
existing.len()
);
continue;
}
log::info!("ensure_active: No {browser} versions found, will download");
// Get the latest release type for this browser
let release_types = match version_manager.get_browser_release_types(browser).await {
+48 -33
View File
@@ -42,7 +42,10 @@ pub struct Downloader {
impl Downloader {
fn new() -> Self {
Self {
client: Client::new(),
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
@@ -304,44 +307,22 @@ impl Downloader {
let file_path = dest_path.join(&download_info.filename);
// Resolve the actual download URL
log::info!(
"Resolving download URL for {} {}",
browser_type.as_str(),
version
);
let download_url = self
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
log::info!("Download URL resolved: {}", download_url);
// Check existing file size — if it matches the expected size, skip download
// Determine if we have a partial file to resume
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
existing_size = meta.len();
}
// Do a HEAD request to get the expected file size for skip/resume decisions
let head_response = self
.client
.head(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await
.ok();
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
// If existing file matches expected size, skip download entirely
if existing_size > 0 {
if let Some(expected) = expected_size {
if existing_size == expected {
log::info!(
"Archive {} already exists with correct size ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
// Satisfiable), delete the partial file and retry once without the Range header.
let response = {
@@ -357,7 +338,13 @@ impl Downloader {
request = request.header("Range", format!("bytes={existing_size}-"));
}
log::info!("Sending download request...");
let first = request.send().await?;
log::info!(
"Download response received: status={}, content-length={:?}",
first.status(),
first.content_length()
);
if first.status().as_u16() == 416 && existing_size > 0 {
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
@@ -415,6 +402,20 @@ impl Downloader {
existing_size = 0;
}
// If the existing file already matches the total size, skip the download
if existing_size > 0 {
if let Some(total) = total_size {
if existing_size >= total {
log::info!(
"Archive {} already complete ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
let mut downloaded = existing_size;
let start_time = std::time::Instant::now();
let mut last_update = start_time;
@@ -445,12 +446,16 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Open file in append mode (resuming) or create new
// Open file in append mode (resuming) or create new.
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
use std::io::Write;
let raw_file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)?;
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -463,7 +468,7 @@ impl Downloader {
}
}
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
let now = std::time::Instant::now();
@@ -510,6 +515,9 @@ impl Downloader {
}
}
// Flush remaining buffered data to disk
file.flush()?;
Ok(file_path)
}
@@ -953,6 +961,13 @@ impl Downloader {
}
}
/// Check if a specific browser-version pair is currently being downloaded
pub fn is_downloading(browser: &str, version: &str) -> bool {
let download_key = format!("{browser}-{version}");
let downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.contains(&download_key)
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
+12 -4
View File
@@ -232,13 +232,21 @@ impl Extractor {
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Always check magic bytes first — the file extension may be wrong
// (e.g. CDN serving a ZIP with .dmg extension)
// Check file extension first for container formats (DMG, MSI) whose internal
// compression makes magic bytes unreliable
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
match ext.to_lowercase().as_str() {
"dmg" => return Ok("dmg".to_string()),
"msi" => return Ok("msi".to_string()),
_ => {}
}
}
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
let mut buffer = [0u8; 12];
file.read_exact(&mut buffer)?;
// Check magic numbers for different file types
// Check magic numbers for other file types
match &buffer[0..4] {
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
return Ok("zip".to_string())
+1 -1
View File
@@ -297,7 +297,7 @@ async fn fetch_dynamic_proxy(
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by routing through a temporary local proxy
// Validate the proxy actually works by connecting through it
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity("_dynamic_test", &settings)
.await
+184 -11
View File
@@ -4,16 +4,18 @@ use axum::{
http::{header, Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::post,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tokio::net::TcpListener;
use tokio::sync::Mutex as AsyncMutex;
use uuid::Uuid;
use crate::browser::ProxySettings;
use crate::cloud_auth::CLOUD_AUTH;
@@ -34,15 +36,20 @@ pub struct McpTool {
#[allow(dead_code)]
pub struct McpRequest {
jsonrpc: String,
id: serde_json::Value,
id: Option<serde_json::Value>,
method: String,
params: Option<serde_json::Value>,
}
const PROTOCOL_VERSION: &str = "2025-03-26";
const SERVER_NAME: &str = "donut-browser";
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Serialize)]
pub struct McpResponse {
jsonrpc: String,
id: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -57,10 +64,15 @@ pub struct McpError {
const DEFAULT_MCP_PORT: u16 = 51080;
struct McpSession {
initialized: bool,
}
struct McpServerInner {
app_handle: Option<AppHandle>,
token: Option<String>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
sessions: HashMap<String, McpSession>,
}
#[derive(Clone)]
@@ -82,6 +94,7 @@ impl McpServer {
app_handle: None,
token: None,
shutdown_tx: None,
sessions: HashMap::new(),
})),
is_running: AtomicBool::new(false),
port: AtomicU16::new(0),
@@ -207,7 +220,13 @@ impl McpServer {
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let app = Router::new()
.route("/mcp", post(Self::handle_mcp_post))
.route(
"/mcp",
post(Self::handle_mcp_post)
.get(Self::handle_mcp_get)
.delete(Self::handle_mcp_delete),
)
.route("/health", get(Self::handle_health))
.layer(middleware::from_fn_with_state(
state.clone(),
Self::auth_middleware,
@@ -243,6 +262,11 @@ impl McpServer {
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Health endpoint is public
if req.uri().path() == "/health" {
return Ok(next.run(req).await);
}
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
@@ -257,12 +281,114 @@ impl McpServer {
Ok(next.run(req).await)
}
async fn handle_mcp_post(
async fn handle_health() -> impl IntoResponse {
Json(serde_json::json!({
"status": "ok",
"server": SERVER_NAME,
"version": SERVER_VERSION,
"protocolVersion": PROTOCOL_VERSION,
}))
}
async fn handle_mcp_get() -> impl IntoResponse {
// We don't support server-initiated SSE streams
StatusCode::METHOD_NOT_ALLOWED
}
async fn handle_mcp_delete(
State(state): State<McpHttpState>,
Json(request): Json<McpRequest>,
req: Request<Body>,
) -> impl IntoResponse {
let response = state.server.handle_request(request).await;
Json(response)
let session_id = req
.headers()
.get("mcp-session-id")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
if let Some(sid) = session_id {
let mut inner = state.server.inner.lock().await;
inner.sessions.remove(&sid);
log::info!("[mcp] Session terminated: {}", sid);
}
StatusCode::OK
}
async fn handle_mcp_post(State(state): State<McpHttpState>, req: Request<Body>) -> Response {
let session_id = req
.headers()
.get("mcp-session-id")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let body_bytes = match axum::body::to_bytes(req.into_body(), 1024 * 1024).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Invalid request body").into_response();
}
};
let request: McpRequest = match serde_json::from_slice(&body_bytes) {
Ok(r) => r,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response();
}
};
let is_notification = request.id.is_none();
let method = request.method.clone();
// Handle initialize (no session required)
if method == "initialize" {
let response = state.server.handle_initialize(request).await;
match response {
Ok((session_id, result)) => {
let body = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(result.0),
result: Some(result.1),
error: None,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header("mcp-session-id", &session_id)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
Err((id, error)) => {
let body = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(id),
result: None,
error: Some(error),
};
Json(body).into_response()
}
}
} else if is_notification {
// Notifications (like notifications/initialized) -> 202 Accepted
if method == "notifications/initialized" {
if let Some(sid) = &session_id {
let mut inner = state.server.inner.lock().await;
if let Some(session) = inner.sessions.get_mut(sid) {
session.initialized = true;
}
}
}
StatusCode::ACCEPTED.into_response()
} else {
// Validate session exists
if let Some(sid) = &session_id {
let inner = state.server.inner.lock().await;
if !inner.sessions.contains_key(sid) {
return StatusCode::NOT_FOUND.into_response();
}
}
let response = state.server.handle_request(request).await;
Json(response).into_response()
}
}
pub async fn stop(&self) -> Result<(), String> {
@@ -273,6 +399,7 @@ impl McpServer {
let mut inner = self.inner.lock().await;
inner.app_handle = None;
inner.token = None;
inner.sessions.clear();
// Send shutdown signal
if let Some(tx) = inner.shutdown_tx.take() {
@@ -1184,11 +1311,56 @@ impl McpServer {
]
}
async fn handle_initialize(
&self,
request: McpRequest,
) -> Result<(String, (serde_json::Value, serde_json::Value)), (serde_json::Value, McpError)> {
let id = request.id.clone().unwrap_or(serde_json::Value::Null);
if !self.is_running() {
return Err((
id,
McpError {
code: -32001,
message: "MCP server is not running".to_string(),
},
));
}
// Create session
let session_id = Uuid::new_v4().to_string();
{
let mut inner = self.inner.lock().await;
inner
.sessions
.insert(session_id.clone(), McpSession { initialized: false });
}
let result = serde_json::json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"tools": {
"listChanged": false
}
},
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION,
},
"instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools."
});
log::info!("[mcp] New session initialized: {}", session_id);
Ok((session_id, (id, result)))
}
pub async fn handle_request(&self, request: McpRequest) -> McpResponse {
let id = request.id.clone().unwrap_or(serde_json::Value::Null);
if !self.is_running() {
return McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
id: Some(id),
result: None,
error: Some(McpError {
code: -32001,
@@ -1198,6 +1370,7 @@ impl McpServer {
}
let result = match request.method.as_str() {
"ping" => Ok(serde_json::json!({})),
"tools/list" => self.handle_tools_list().await,
"tools/call" => self.handle_tool_call(request.params).await,
_ => Err(McpError {
@@ -1209,13 +1382,13 @@ impl McpServer {
match result {
Ok(value) => McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
id: Some(id),
result: Some(value),
error: None,
},
Err(error) => McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
id: Some(id),
result: None,
error: Some(error),
},
+38 -2
View File
@@ -425,8 +425,21 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(metadata_file)?;
let profile: BrowserProfile = serde_json::from_str(&content)?;
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
if profile.host_os.is_none() {
let inferred_os = profile.resolved_os().map(str::to_string);
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
}
}
}
profiles.push(profile);
}
}
@@ -566,6 +579,29 @@ impl ProfileManager {
Ok(())
}
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
pub fn delete_profile_local_only(
&self,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir();
let profile_dir = profiles_dir.join(profile_id);
if profile_dir.exists() {
fs::remove_dir_all(&profile_dir)?;
log::info!("Deleted local profile {} (tombstoned remotely)", profile_id);
}
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
.cleanup_unused_binaries()
{
log::warn!("Failed to cleanup binaries after tombstone deletion: {e}");
}
let _ = crate::events::emit_empty("profiles-changed");
Ok(())
}
pub fn update_profile_version(
&self,
_app_handle: &tauri::AppHandle,
+14 -3
View File
@@ -87,11 +87,22 @@ impl BrowserProfile {
profiles_dir.join(self.id.to_string()).join("profile")
}
/// Resolve the OS this profile was created on. Checks `host_os` first,
/// then falls back to the fingerprint config's `os` field (for profiles
/// created before `host_os` was introduced or synced without it).
pub fn resolved_os(&self) -> Option<&str> {
self
.host_os
.as_deref()
.or_else(|| self.camoufox_config.as_ref().and_then(|c| c.os.as_deref()))
.or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
}
/// Returns true when the profile was created on a different OS than the current host.
/// Profiles without an `os` field (backward compat) are treated as native.
/// Checks `host_os` first, then falls back to the browser config's `os` field.
pub fn is_cross_os(&self) -> bool {
match &self.host_os {
Some(host_os) => host_os != &get_host_os(),
match self.resolved_os() {
Some(os) => os != get_host_os(),
None => false,
}
}
+3 -1
View File
@@ -1035,7 +1035,9 @@ Path=test.profile
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
let result = importer.get_default_version_for_browser("camoufox");
// Use a browser name that is guaranteed to have no downloaded versions,
// since the global registry singleton may contain real data from the system.
let result = importer.get_default_version_for_browser("nonexistent_browser_xyz");
assert!(
result.is_err(),
"Should fail when no versions are available"
+131 -25
View File
@@ -907,6 +907,63 @@ impl ProxyManager {
.map(|p| p.proxy_settings.clone())
}
fn classify_proxy_error(raw_error: &str, settings: &ProxySettings) -> String {
let err = raw_error.to_lowercase();
let proxy_addr = format!("{}:{}", settings.host, settings.port);
if err.contains("connection refused") {
return format!(
"Connection refused by {proxy_addr}. The proxy server is not accepting connections."
);
}
if err.contains("connection reset") {
return format!(
"Connection reset by {proxy_addr}. The proxy server closed the connection unexpectedly."
);
}
if err.contains("timed out") || err.contains("deadline has elapsed") {
return format!("Connection to {proxy_addr} timed out. The proxy server is not responding.");
}
if err.contains("no such host") || err.contains("dns") || err.contains("resolve") {
return format!(
"Could not resolve proxy host '{}'. Check that the hostname is correct.",
settings.host
);
}
if err.contains("authentication") || err.contains("407") || err.contains("proxy auth") {
return format!(
"Proxy authentication failed for {proxy_addr}. Check your username and password."
);
}
if err.contains("403") || err.contains("forbidden") {
return format!("Access denied by {proxy_addr} (403 Forbidden).");
}
if err.contains("402") {
return format!(
"Payment required by {proxy_addr} (402). Your proxy subscription may have expired."
);
}
if err.contains("502") || err.contains("bad gateway") {
return format!(
"Bad gateway from {proxy_addr} (502). The upstream proxy server may be down."
);
}
if err.contains("503") || err.contains("service unavailable") {
return format!("Proxy {proxy_addr} is temporarily unavailable (503).");
}
if err.contains("socks") && err.contains("unreachable") {
return format!("SOCKS proxy {proxy_addr} could not reach the target. The proxy server may not have internet access.");
}
if err.contains("invalid proxy") || err.contains("unsupported proxy") {
return format!(
"Invalid proxy configuration for {proxy_addr}. Check the proxy type and address."
);
}
// Generic fallback — still show the proxy address for context
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
}
// Build proxy URL string from ProxySettings
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
let mut url = format!("{}://", proxy_settings.proxy_type);
@@ -928,9 +985,9 @@ impl ProxyManager {
url
}
// Check if a proxy is valid by routing through a temporary local donut-proxy.
// This tests the exact same code path the browser uses, ensuring that if the
// check passes, the browser connection will work too.
// Check if a proxy is valid by routing through a temporary donut-proxy process.
// This tests the exact same code path the browser uses.
// Falls back to direct reqwest check if the proxy worker fails to start.
pub async fn check_proxy_validity(
&self,
proxy_id: &str,
@@ -938,19 +995,31 @@ impl ProxyManager {
) -> Result<ProxyCheckResult, String> {
let upstream_url = Self::build_proxy_url(proxy_settings);
// Start a temporary local proxy that tunnels through the upstream
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
.await
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
// Try process-based check first (identical to browser launch path)
// Try process-based check first (identical to browser launch path).
// If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing
// restrictions), fall back to a direct reqwest check.
let proxy_start_result =
crate::proxy_runner::start_proxy_process(Some(upstream_url.clone()), None)
.await
.map_err(|e| e.to_string());
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let proxy_id_clone = proxy_config.id.clone();
// Fetch public IP through the local proxy (same path the browser uses)
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
// Stop the temporary proxy regardless of result
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
let ip_result = match proxy_start_result {
Ok(proxy_config) => {
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let config_id = proxy_config.id.clone();
let result = ip_utils::fetch_public_ip(Some(&local_url)).await;
let _ = crate::proxy_runner::stop_proxy_process(&config_id).await;
result
}
Err(err_msg) => {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
}
};
let ip = match ip_result {
Ok(ip) => ip,
@@ -964,7 +1033,10 @@ impl ProxyManager {
is_valid: false,
};
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
return Err(format!("Failed to fetch public IP: {e}"));
let err_str = e.to_string();
let user_message = Self::classify_proxy_error(&err_str, proxy_settings);
return Err(user_message);
}
};
@@ -1049,12 +1121,21 @@ impl ProxyManager {
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let host = obj
let raw_host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?
.to_string();
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
// and extract the proxy type from it if no explicit type field is provided
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
(rest.to_string(), None)
} else if let Some((proto, rest)) = raw_host.split_once("://") {
(rest.to_string(), Some(proto.to_lowercase()))
} else {
(raw_host.to_string(), None)
};
let port = obj
.get("port")
@@ -1070,8 +1151,9 @@ impl ProxyManager {
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.unwrap_or("http")
.to_lowercase();
.map(|s| s.to_lowercase())
.or(protocol_from_host)
.unwrap_or_else(|| "http".to_string());
let username = obj
.get("username")
@@ -2731,17 +2813,19 @@ mod tests {
fn test_process_running_detection_with_child_lifecycle() {
use crate::proxy_storage::is_process_running;
// Spawn a long-lived child so we can check while it runs
let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" })
// Spawn a long-lived child so we can check while it runs.
// On Windows, `timeout` requires console input and exits immediately in
// non-interactive contexts, so use `ping` with a high count instead.
let mut child = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" })
.args(if cfg!(windows) {
vec!["/T", "10"]
vec!["-n", "100", "127.0.0.1"]
} else {
vec!["10"]
})
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn sleep");
.expect("spawn long-lived child");
let pid = child.id();
@@ -3470,6 +3554,28 @@ mod tests {
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.port, 1080);
// Protocol in host should be used as proxy_type when no explicit type field
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.host, "10.0.0.1");
assert_eq!(result2.proxy_type, "http");
// Explicit type field takes precedence over protocol in host
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.host, "10.0.0.1");
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
+83 -136
View File
@@ -883,6 +883,87 @@ fn build_reqwest_client_with_proxy(
Ok(client_builder.proxy(proxy).build()?)
}
/// Handle a single proxy connection (used by both the proxy worker and in-process proxy checks).
pub async fn handle_proxy_connection(
mut stream: tokio::net::TcpStream,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) {
let _ = stream.set_nodelay(true);
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {}
Ok(n) => {
let request_start_upper = String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
if is_connect {
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100;
let mut reads = 0;
loop {
if reads >= max_reads {
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
if full_request.ends_with(b"\r\n\r\n")
|| full_request.ends_with(b"\n\n")
|| total_read > 0
{
break;
}
return;
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
}
Err(_) => {
if total_read > 0 {
break;
}
return;
}
}
}
let _ =
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
return;
}
// Non-CONNECT: prepend consumed bytes and pass to hyper
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
let _ = http1::Builder::new().serve_connection(io, service).await;
}
Err(_) => {}
}
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::error!(
"Proxy worker starting, looking for config id: {}",
@@ -1052,145 +1133,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// This ensures the process doesn't exit even if there are no active connections
loop {
match listener.accept().await {
Ok((mut stream, peer_addr)) => {
// Enable TCP_NODELAY to ensure small packets are sent immediately
// This is critical for CONNECT responses to be sent before tunneling begins
let _ = stream.set_nodelay(true);
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
Ok((stream, _peer_addr)) => {
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Wait for the stream to have readable data before attempting to read.
// This prevents read() from returning 0 on a fresh connection before
// the client's data arrives.
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
let request_start_upper =
String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
log::error!(
"DEBUG: Read {} bytes, starts with: {:?}, is_connect: {}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(20)]),
is_connect
);
if is_connect {
// Handle CONNECT request manually for tunneling
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
// Read the rest of the CONNECT request until we have the full headers
// CONNECT requests end with \r\n\r\n (or \n\n)
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100; // Prevent infinite loop
let mut reads = 0;
loop {
if reads >= max_reads {
log::error!("DEBUG: Max reads reached, breaking");
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
// Connection closed, but we might have a complete request
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// If we have some data, try to process it anyway
if total_read > 0 {
break;
}
return; // No data at all
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
// Check if we have complete headers
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// Also check if we have enough to parse (at least "CONNECT host:port HTTP/1.x")
if total_read >= 20 {
// Check if we have a newline that might indicate end of request line
if let Some(pos) = full_request.iter().position(|&b| b == b'\n') {
if pos < full_request.len() - 1 {
// We have at least the request line, check if we have headers
let request_str = String::from_utf8_lossy(&full_request);
if request_str.contains("\r\n\r\n") || request_str.contains("\n\n") {
break;
}
}
}
}
}
Err(e) => {
log::error!("DEBUG: Error reading CONNECT request: {:?}", e);
// If we have some data, try to process it
if total_read > 0 {
break;
}
return;
}
}
}
// Handle CONNECT manually
log::error!(
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) =
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
{
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
}
return;
}
// Not CONNECT (or partial read) - reconstruct stream with consumed bytes prepended
// This is critical: we MUST prepend any bytes we consumed, even if < 7 bytes
log::error!(
"DEBUG: Non-CONNECT request, first {} bytes: {:?}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(50)])
);
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
}
}
Err(e) => {
log::error!("Error reading from connection: {:?}", e);
}
}
handle_proxy_connection(stream, upstream, matcher).await;
});
}
Err(e) => {
+93 -9
View File
@@ -597,15 +597,35 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Update profile last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
// Download remote metadata and merge changes (name, tags, notes, etc.)
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated_profile = profile.clone();
// Merge fields that can be changed on other devices
updated_profile.name = remote_meta.name;
updated_profile.tags = remote_meta.tags;
updated_profile.note = remote_meta.note;
updated_profile.proxy_id = remote_meta.proxy_id;
updated_profile.vpn_id = remote_meta.vpn_id;
updated_profile.group_id = remote_meta.group_id;
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
} else {
// Fallback: just update last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
}
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
@@ -691,6 +711,22 @@ impl SyncEngine {
Ok(())
}
async fn download_profile_metadata(&self, key: &str) -> SyncResult<BrowserProfile> {
let stat = self.client.stat(key).await?;
if !stat.exists {
return Err(SyncError::InvalidData(
"Remote metadata not found".to_string(),
));
}
let presign = self.client.presign_download(key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let profile: BrowserProfile = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
Ok(profile)
}
async fn upload_profile_metadata(
&self,
profile_id: &str,
@@ -2361,6 +2397,54 @@ impl SyncEngine {
log::info!("No missing profiles found");
}
// Delete local synced profiles that have a remote tombstone (deleted on another device)
{
let profile_manager = ProfileManager::instance();
let local_synced: Vec<(String, Option<String>)> = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.filter(|p| p.is_sync_enabled())
.map(|p| (p.id.to_string(), p.created_by_id.clone()))
.collect();
let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
auth.user.team_id.map(|tid| format!("teams/{}/", tid))
} else {
None
};
for (pid, created_by_id) in &local_synced {
// Check personal tombstone
let personal_tombstone = format!("tombstones/profiles/{}.json", pid);
let has_personal_tombstone = matches!(
self.client.stat(&personal_tombstone).await,
Ok(stat) if stat.exists
);
// Check team tombstone
let has_team_tombstone = if let (Some(tp), Some(_)) = (&team_prefix, created_by_id) {
let team_tombstone = format!("{}tombstones/profiles/{}.json", tp, pid);
matches!(
self.client.stat(&team_tombstone).await,
Ok(stat) if stat.exists
)
} else {
false
};
if has_personal_tombstone || has_team_tombstone {
log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid
);
if let Err(e) = profile_manager.delete_profile_local_only(pid) {
log::warn!("Failed to delete tombstoned profile {}: {}", pid, e);
}
}
}
}
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
let profile_manager = ProfileManager::instance();
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
+10 -88
View File
@@ -153,30 +153,20 @@ impl SyncScheduler {
}
pub async fn is_profile_running(&self, profile_id: &str) -> bool {
// First check our internal tracking
// Check our internal tracking (authoritative — immediately updated by mark_profile_stopped)
let running = self.running_profiles.lock().await;
if running.contains(profile_id) {
return true;
}
drop(running);
// Also check the actual profile state from ProfileManager
let profile_manager = ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.process_id.is_some() {
return true;
}
}
}
// Check if locked by another team member (profile in use remotely)
if crate::team_lock::TEAM_LOCK
// Check if locked by another device (profile in use remotely)
if crate::team_lock::PROFILE_LOCK
.is_locked_by_another(profile_id)
.await
{
log::debug!(
"Profile {} is locked by another team member, treating as running",
"Profile {} is locked on another device, treating as running",
profile_id
);
return true;
@@ -477,31 +467,12 @@ impl SyncScheduler {
});
}
// Wait for all parallel syncs to finish
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
}
}
// Trigger cleanup if everything is done
let all_done = {
let in_flight = self.in_flight_profiles.lock().await;
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
if all_done {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
// Wait for all parallel syncs to finish (only if we actually spawned any)
if !sync_set.is_empty() {
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
}
}
}
}
@@ -556,16 +527,6 @@ impl SyncScheduler {
}
// Check if all sync work is complete after proxies finish
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after proxy sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
@@ -623,16 +584,6 @@ impl SyncScheduler {
}
// Check if all sync work is complete after groups finish
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after group sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
@@ -685,17 +636,6 @@ impl SyncScheduler {
}
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after VPN sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
@@ -725,15 +665,6 @@ impl SyncScheduler {
log::error!("Failed to sync extension {}: {}", ext_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
@@ -763,15 +694,6 @@ impl SyncScheduler {
log::error!("Failed to sync extension group {}: {}", group_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension group sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
+10 -4
View File
@@ -233,10 +233,16 @@ impl SyncSubscription {
let key = Self::strip_team_prefix(raw_key);
let work_item = if key.starts_with("profiles/") {
key
.strip_prefix("profiles/")
.and_then(|s| s.strip_suffix(".tar.gz"))
.map(|s| SyncWorkItem::Profile(s.to_string()))
// Match both bundle uploads (profiles/{id}.tar.gz) and delta sync updates
// (profiles/{id}/manifest.json, profiles/{id}/files/*, profiles/{id}/metadata.json)
let profile_id = key.strip_prefix("profiles/").and_then(|rest| {
// profiles/{id}.tar.gz → id
rest
.strip_suffix(".tar.gz")
// profiles/{id}/manifest.json → id
.or_else(|| rest.split('/').next().filter(|s| !s.is_empty()))
});
profile_id.map(|s| SyncWorkItem::Profile(s.to_string()))
} else if key.starts_with("proxies/") {
key
.strip_prefix("proxies/")
+56 -61
View File
@@ -31,42 +31,45 @@ struct AcquireLockResponse {
locked_by_email: Option<String>,
}
pub struct TeamLockManager {
pub struct ProfileLockManager {
locks: RwLock<HashMap<String, ProfileLockInfo>>,
heartbeat_handle: Mutex<Option<JoinHandle<()>>>,
connected_team_id: Mutex<Option<String>>,
connected: Mutex<bool>,
}
lazy_static! {
pub static ref TEAM_LOCK: TeamLockManager = TeamLockManager::new();
pub static ref PROFILE_LOCK: ProfileLockManager = ProfileLockManager::new();
}
impl TeamLockManager {
// Keep backward compatibility alias
pub use PROFILE_LOCK as TEAM_LOCK;
impl ProfileLockManager {
fn new() -> Self {
Self {
locks: RwLock::new(HashMap::new()),
heartbeat_handle: Mutex::new(None),
connected_team_id: Mutex::new(None),
connected: Mutex::new(false),
}
}
pub async fn connect(&self, team_id: &str) {
log::info!("Connecting team lock manager for team: {team_id}");
pub async fn connect(&self) {
log::info!("Connecting profile lock manager");
{
let mut tid = self.connected_team_id.lock().await;
*tid = Some(team_id.to_string());
let mut c = self.connected.lock().await;
*c = true;
}
if let Err(e) = self.fetch_initial_locks(team_id).await {
log::warn!("Failed to fetch initial locks: {e}");
if let Err(e) = self.fetch_locks().await {
log::warn!("Failed to fetch initial profile locks: {e}");
}
self.start_heartbeat_loop().await;
}
pub async fn disconnect(&self) {
log::info!("Disconnecting team lock manager");
log::info!("Disconnecting profile lock manager");
{
let mut handle = self.heartbeat_handle.lock().await;
@@ -81,23 +84,24 @@ impl TeamLockManager {
}
{
let mut tid = self.connected_team_id.lock().await;
*tid = None;
let mut c = self.connected.lock().await;
*c = false;
}
}
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
let team_id = self.get_team_id().await?;
let client = Client::new();
pub async fn is_connected(&self) -> bool {
*self.connected.lock().await
}
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.json(&serde_json::json!({ "profileId": profile_id }))
.send()
.await
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
@@ -116,7 +120,7 @@ impl TeamLockManager {
if !result.success {
let email = result
.locked_by_email
.unwrap_or_else(|| "another user".to_string());
.unwrap_or_else(|| "another device".to_string());
return Err(format!("Profile is in use by {email}"));
}
@@ -136,21 +140,19 @@ impl TeamLockManager {
}
let _ = crate::events::emit(
"team-lock-acquired",
serde_json::json!({ "profileId": profile_id }),
"profile-lock-changed",
serde_json::json!({ "profileId": profile_id, "action": "acquired" }),
);
Ok(())
}
pub async fn release_lock(&self, profile_id: &str) -> Result<(), String> {
let team_id = self.get_team_id().await?;
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}");
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
let _ = client
.delete(&url)
.header("Authorization", format!("Bearer {access_token}"))
@@ -163,8 +165,8 @@ impl TeamLockManager {
}
let _ = crate::events::emit(
"team-lock-released",
serde_json::json!({ "profileId": profile_id }),
"profile-lock-changed",
serde_json::json!({ "profileId": profile_id, "action": "released" }),
);
Ok(())
@@ -190,12 +192,12 @@ impl TeamLockManager {
false
}
async fn fetch_initial_locks(&self, team_id: &str) -> Result<(), String> {
async fn fetch_locks(&self) -> Result<(), String> {
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
let url = format!("{CLOUD_API_URL}/api/profile-locks");
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
@@ -231,13 +233,13 @@ impl TeamLockManager {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let team_id = match TEAM_LOCK.get_team_id().await {
Ok(id) => id,
Err(_) => break,
};
if !PROFILE_LOCK.is_connected().await {
break;
}
// Send heartbeat for each held lock
let held_locks: Vec<String> = {
let locks = TEAM_LOCK.locks.read().await;
let locks = PROFILE_LOCK.locks.read().await;
if let Some(user) = CLOUD_AUTH.get_user().await {
locks
.values()
@@ -252,7 +254,7 @@ impl TeamLockManager {
for profile_id in held_locks {
let client = Client::new();
if let Ok(Some(token)) = CloudAuthManager::load_access_token() {
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}/heartbeat");
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}/heartbeat");
let _ = client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
@@ -262,63 +264,56 @@ impl TeamLockManager {
}
// Refresh lock state from server
if let Err(e) = TEAM_LOCK.fetch_initial_locks(&team_id).await {
log::debug!("Failed to refresh locks: {e}");
if let Err(e) = PROFILE_LOCK.fetch_locks().await {
log::debug!("Failed to refresh profile locks: {e}");
}
}
});
*handle = Some(h);
}
async fn get_team_id(&self) -> Result<String, String> {
let tid = self.connected_team_id.lock().await;
tid
.clone()
.ok_or_else(|| "Not connected to a team".to_string())
}
}
/// Acquire team lock if profile is sync-enabled and user is on a team.
/// Returns Ok(()) if lock acquired or not applicable, Err with message if locked by another.
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
pub async fn acquire_team_lock_if_needed(
profile: &crate::profile::BrowserProfile,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
return Ok(());
}
if !CLOUD_AUTH.is_on_team_plan().await {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Ok(());
}
if TEAM_LOCK
// Ensure lock manager is connected
if !PROFILE_LOCK.is_connected().await {
PROFILE_LOCK.connect().await;
}
if PROFILE_LOCK
.is_locked_by_another(&profile.id.to_string())
.await
{
if let Some(lock) = TEAM_LOCK.get_lock_status(&profile.id.to_string()).await {
if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await {
return Err(format!("Profile is in use by {}", lock.locked_by_email));
}
return Err("Profile is in use by another team member".to_string());
return Err("Profile is in use on another device".to_string());
}
TEAM_LOCK.acquire_lock(&profile.id.to_string()).await
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
}
/// Release team lock if profile is sync-enabled and user is on a team.
/// Logs warnings on failure but does not return errors.
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
if !CLOUD_AUTH.is_on_team_plan().await {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return;
}
if let Err(e) = TEAM_LOCK.release_lock(&profile.id.to_string()).await {
log::warn!(
"Failed to release team lock for profile {}: {e}",
profile.id
);
if let Err(e) = PROFILE_LOCK.release_lock(&profile.id.to_string()).await {
log::warn!("Failed to release profile lock for {}: {e}", profile.id);
}
}
@@ -326,10 +321,10 @@ pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfil
#[tauri::command]
pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
Ok(TEAM_LOCK.get_locks().await)
Ok(PROFILE_LOCK.get_locks().await)
}
#[tauri::command]
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
Ok(TEAM_LOCK.get_lock_status(&profile_id).await)
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.17.1",
"version": "0.17.6",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+3
View File
@@ -361,6 +361,9 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
{t("integrations.mcpCopyHint")}
</p>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpConfigPath")}
</p>
</div>
)}
</TabsContent>
+24 -11
View File
@@ -1648,14 +1648,18 @@ export function ProfilesDataTable({
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
const resolvedOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
const osName = resolvedOs
? getOSDisplayName(resolvedOs)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
const OsIcon =
profile.host_os === "macos"
resolvedOs === "macos"
? FaApple
: profile.host_os === "windows"
: resolvedOs === "windows"
? FaWindows
: FaLinux;
return (
@@ -1684,8 +1688,12 @@ export function ProfilesDataTable({
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
const resolvedOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
const osName = resolvedOs
? getOSDisplayName(resolvedOs)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
return (
@@ -2017,7 +2025,7 @@ export function ProfilesDataTable({
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2078,7 +2086,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2107,7 +2115,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2134,7 +2142,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2534,7 +2542,12 @@ export function ProfilesDataTable({
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(row.original.host_os ?? ""),
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
+16 -4
View File
@@ -211,7 +211,7 @@ export function ProfileInfoDialog({
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = !!(profile.host_os && isCrossOsProfile(profile));
const showCrossOs = isCrossOsProfile(profile);
type ActionItem = {
icon: React.ReactNode;
@@ -364,10 +364,22 @@ export function ProfileInfoDialog({
{t("profiles.ephemeralBadge")}
</Badge>
)}
{showCrossOs && profile.host_os && (
{showCrossOs && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</Badge>
)}
</div>
+11 -8
View File
@@ -15,7 +15,7 @@ export function useBrowserState(
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
crossOsUnlocked = false,
_crossOsUnlocked = false,
) {
const [isClient, setIsClient] = useState(false);
@@ -53,7 +53,7 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile) && !crossOsUnlocked) return false;
if (isCrossOsProfile(profile)) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
@@ -81,7 +81,6 @@ export function useBrowserState(
isAnyInstanceRunning,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
@@ -158,11 +157,16 @@ export function useBrowserState(
(profile: BrowserProfile): string => {
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
if (!crossOsUnlocked) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`;
if (isCrossOsProfile(profile)) {
const profileOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
if (profileOs) {
const osName = getOSDisplayName(profileOs);
return `This profile was created on ${osName} and cannot be launched on a different operating system.`;
}
return "This profile was created on a different operating system and cannot be launched here.";
}
const isRunning = runningProfiles.has(profile.id);
@@ -197,7 +201,6 @@ export function useBrowserState(
canLaunchProfile,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
},
"mcpCopyHint": "Add this to your MCP client config to connect."
"mcpCopyHint": "Copy and paste this into your MCP client configuration.",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "Import Profile",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
},
"mcpCopyHint": "Agrega esto a la configuración de tu cliente MCP para conectarte."
"mcpCopyHint": "Copia y pega esto en la configuración de tu cliente MCP.",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) o %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "Importar Perfil",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
},
"mcpCopyHint": "Ajoutez ceci à la configuration de votre client MCP pour vous connecter."
"mcpCopyHint": "Copiez et collez ceci dans la configuration de votre client MCP.",
"mcpConfigPath": "Claude Desktop : ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "Importer un profil",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "MCP設定",
"copyConfig": "設定をコピー"
},
"mcpCopyHint": "MCPクライアントの設定にこれを追加して接続してください。"
"mcpCopyHint": "MCPクライアントの設定にコピーして貼り付けてください。",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) または %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "プロファイルをインポート",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
},
"mcpCopyHint": "Adicione isso à configuração do seu cliente MCP para conectar."
"mcpCopyHint": "Copie e cole isso na configuração do seu cliente MCP.",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "Importar Perfil",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
},
"mcpCopyHint": "Добавьте это в конфигурацию вашего MCP-клиента для подключения."
"mcpCopyHint": "Скопируйте и вставьте это в конфигурацию вашего MCP-клиента.",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) или %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "Импорт профиля",
+2 -1
View File
@@ -421,7 +421,8 @@
"config": "MCP 配置",
"copyConfig": "复制配置"
},
"mcpCopyHint": "将此添加到您的MCP客户端配置中以进行连接。"
"mcpCopyHint": "将此内容复制并粘贴到您的 MCP 客户端配置中。",
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) 或 %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
},
"import": {
"title": "导入配置文件",
+11 -3
View File
@@ -57,9 +57,17 @@ export const getCurrentOS = () => {
return "unknown";
};
export function isCrossOsProfile(profile: { host_os?: string }): boolean {
if (!profile.host_os) return false;
return profile.host_os !== getCurrentOS();
export function isCrossOsProfile(profile: {
host_os?: string;
camoufox_config?: { os?: string };
wayfern_config?: { os?: string };
}): boolean {
const profileOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
if (!profileOs) return false;
return profileOs !== getCurrentOS();
}
export function getOSDisplayName(os: string): string {