v0.19.0: 浏览器拦截器 - macOS 默认浏览器接管功能

新增功能:
- 浏览器拦截器模块,支持 macOS 系统默认浏览器接管
- 自动拦截系统 URL 请求并转发到 AI 客户端
- URL 历史记录管理与复制功能
- 系统状态监控面板
- 支持 Chrome/Firefox/Safari 等多浏览器检测与恢复

技术改进:
- 使用 tauri-plugin-deep-link 实现 URL scheme 注册
- 使用 tauri-plugin-single-instance 防止多实例
- Launch Services API 集成实现浏览器管理
- 修复速率限制测试的不稳定性问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2025-12-25 21:30:43 +08:00
co-authored by Claude Opus 4.5
parent 35b2636bce
commit ba0af7ebf0
56 changed files with 8666 additions and 136 deletions
+2
View File
@@ -4,6 +4,8 @@ node_modules/
# Build
dist/
src-tauri/target/
*.exe
*.pdb
# IDE
.vscode/
+81 -2
View File
@@ -8,6 +8,7 @@
"name": "proxycast",
"version": "0.18.0",
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "^2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
@@ -17,16 +18,18 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-shell": "^2.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.4"
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
@@ -1191,6 +1194,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fabianlars/tauri-plugin-oauth": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fabianlars/tauri-plugin-oauth/-/tauri-plugin-oauth-2.0.0.tgz",
"integrity": "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.3"
}
},
"node_modules/@fast-check/vitest": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@fast-check/vitest/-/vitest-0.2.4.tgz",
@@ -4072,6 +4084,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@@ -4177,6 +4202,16 @@
"node": ">=20"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5954,6 +5989,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"license": "MIT",
"dependencies": {
"react-router": "7.11.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -6156,6 +6229,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.18.0",
"version": "0.19.0",
"type": "module",
"repository": {
"type": "git",
@@ -29,16 +29,19 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-shell": "^2.0.0",
"@fabianlars/tauri-plugin-oauth": "^2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.4"
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
+472 -15
View File
@@ -70,6 +70,26 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
@@ -109,6 +129,79 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -120,6 +213,24 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -142,6 +253,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -364,6 +481,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -382,6 +505,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -609,6 +745,15 @@ dependencies = [
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.56"
@@ -618,6 +763,35 @@ dependencies = [
"cc",
]
[[package]]
name = "cocoa"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c"
dependencies = [
"bitflags 2.10.0",
"block",
"cocoa-foundation",
"core-foundation 0.10.1",
"core-graphics",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d"
dependencies = [
"bitflags 2.10.0",
"block",
"core-foundation 0.10.1",
"core-graphics-types",
"objc",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -637,6 +811,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
@@ -742,6 +936,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1000,6 +1200,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1125,6 +1334,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1544,6 +1759,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1787,6 +2012,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1937,7 +2168,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@@ -2092,6 +2323,7 @@ dependencies = [
"byteorder-lite",
"num-traits",
"png",
"tiff",
]
[[package]]
@@ -2251,6 +2483,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "js-sys"
version = "0.3.83"
@@ -2437,6 +2675,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2691,6 +2938,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc2"
version = "0.6.3"
@@ -2971,6 +3227,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3226,6 +3492,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3258,6 +3535,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3377,9 +3668,10 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
@@ -3387,6 +3679,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"chrono",
"cocoa",
"dashmap",
"dirs 5.0.1",
"flate2",
@@ -3395,6 +3688,7 @@ dependencies = [
"indexmap 2.12.1",
"md5",
"notify",
"objc",
"once_cell",
"open",
"parking_lot",
@@ -3413,8 +3707,10 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tempfile",
"thiserror 1.0.69",
"tiktoken-rs",
@@ -3427,6 +3723,8 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"winapi",
"windows 0.56.0",
"zip",
]
@@ -3770,6 +4068,16 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4528,7 +4836,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4600,7 +4908,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4697,6 +5005,27 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.2"
@@ -4758,6 +5087,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.9.2"
@@ -4780,7 +5124,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4806,7 +5150,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4932,6 +5276,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]]
name = "tiktoken-rs"
version = "0.6.0"
@@ -4979,6 +5334,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -5770,10 +6134,10 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -5794,10 +6158,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [
"thiserror 2.0.17",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5844,6 +6214,16 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core 0.56.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5866,14 +6246,26 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement 0.56.0",
"windows-interface 0.56.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5885,8 +6277,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5903,6 +6295,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5914,6 +6317,17 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5947,6 +6361,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -5958,6 +6383,15 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -6397,7 +6831,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -6424,6 +6858,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "yoke"
version = "0.8.1"
@@ -6454,8 +6905,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
+35 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.18.0"
version = "0.19.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -19,6 +19,7 @@ tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -61,6 +62,39 @@ open = "5"
url = "2"
once_cell = "1"
tokio-util = "0.7"
arboard = "3"
# Platform specific dependencies for browser interceptor
# Windows specific dependencies for browser interceptor
[target.'cfg(windows)'.dependencies]
windows = { version = "0.56", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_System_ProcessStatus",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Diagnostics_ToolHelp",
"Win32_Security",
] }
winapi = { version = "0.3", features = [
"winuser",
"winreg",
"processthreadsapi",
"handleapi",
"shellapi",
"psapi",
"tlhelp32",
] }
# macOS specific dependencies for browser interceptor
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
objc = "0.2"
tauri-plugin-deep-link = "2"
[dev-dependencies]
proptest = "1"
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Web URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>ProxyCast URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>proxycast</string>
</array>
</dict>
</array>
</dict>
</plist>
File diff suppressed because one or more lines are too long
+54
View File
@@ -2396,6 +2396,60 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
"type": "string",
"const": "deep-link:default",
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
},
{
"description": "Enables the get_current command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-get-current",
"markdownDescription": "Enables the get_current command without any pre-configured scope."
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Denies the get_current command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-get-current",
"markdownDescription": "Denies the get_current command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
+54
View File
@@ -2396,6 +2396,60 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
"type": "string",
"const": "deep-link:default",
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
},
{
"description": "Enables the get_current command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-get-current",
"markdownDescription": "Enables the get_current command without any pre-configured scope."
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "deep-link:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Denies the get_current command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-get-current",
"markdownDescription": "Denies the get_current command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "deep-link:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
+266
View File
@@ -0,0 +1,266 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 拦截器状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptorState {
pub enabled: bool,
pub active_hooks: Vec<String>,
pub intercepted_count: u32,
pub last_activity: Option<DateTime<Utc>>,
pub can_restore: bool, // 是否可以恢复正常状态
}
impl Default for InterceptorState {
fn default() -> Self {
Self {
enabled: false,
active_hooks: Vec::new(),
intercepted_count: 0,
last_activity: None,
can_restore: false,
}
}
}
/// 被拦截的 URL 信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptedUrl {
pub id: String,
pub url: String,
pub source_process: String,
pub timestamp: DateTime<Utc>,
pub copied: bool,
pub opened_in_browser: bool,
pub dismissed: bool,
}
impl InterceptedUrl {
pub fn new(url: String, source_process: String) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
url,
source_process,
timestamp: Utc::now(),
copied: false,
opened_in_browser: false,
dismissed: false,
}
}
}
/// 指纹浏览器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FingerprintBrowserConfig {
pub enabled: bool,
pub executable_path: String,
pub profile_path: String,
pub additional_args: Vec<String>,
}
impl Default for FingerprintBrowserConfig {
fn default() -> Self {
Self {
enabled: false,
executable_path: String::new(),
profile_path: String::new(),
additional_args: Vec::new(),
}
}
}
/// 恢复机制配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryConfig {
pub backup_system_state: bool,
pub emergency_recovery_hotkey: String,
pub auto_recovery_on_crash: bool,
pub recovery_timeout: u64, // 恢复操作超时时间(秒)
}
impl Default for RecoveryConfig {
fn default() -> Self {
Self {
backup_system_state: true,
emergency_recovery_hotkey: "Ctrl+Alt+Shift+R".to_string(),
auto_recovery_on_crash: true,
recovery_timeout: 30,
}
}
}
/// 浏览器拦截器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserInterceptorConfig {
pub enabled: bool,
pub target_processes: Vec<String>,
pub url_patterns: Vec<String>,
pub excluded_processes: Vec<String>,
pub notification_enabled: bool,
pub auto_copy_to_clipboard: bool,
pub auto_launch_browser: bool,
pub restore_on_exit: bool,
pub temporary_disable_timeout: Option<u64>, // 临时禁用超时(秒)
pub fingerprint_browser: FingerprintBrowserConfig,
pub recovery: RecoveryConfig,
}
impl Default for BrowserInterceptorConfig {
fn default() -> Self {
Self {
enabled: false,
target_processes: vec![
"kiro".to_string(),
"kiro.exe".to_string(),
"cursor".to_string(),
"cursor.exe".to_string(),
"code".to_string(),
"code.exe".to_string(),
"Kiro".to_string(), // macOS 应用名称通常首字母大写
"Cursor".to_string(),
"Visual Studio Code".to_string(),
],
url_patterns: vec![
"https://auth.*".to_string(),
"https://*/oauth/*".to_string(),
"https://accounts.google.com/*".to_string(),
"https://github.com/login/*".to_string(),
"https://login.microsoftonline.com/*".to_string(),
],
excluded_processes: vec![
"explorer.exe".to_string(),
"winlogon.exe".to_string(),
"system".to_string(),
"chrome.exe".to_string(),
"firefox.exe".to_string(),
"safari".to_string(),
"Safari".to_string(), // macOS Safari
"Google Chrome".to_string(), // macOS Chrome
"Firefox".to_string(), // macOS Firefox
],
notification_enabled: true,
auto_copy_to_clipboard: true,
auto_launch_browser: false,
restore_on_exit: true,
temporary_disable_timeout: Some(300), // 5分钟
fingerprint_browser: FingerprintBrowserConfig::default(),
recovery: RecoveryConfig::default(),
}
}
}
impl BrowserInterceptorConfig {
/// 检查进程是否在目标列表中
pub fn is_target_process(&self, process_name: &str) -> bool {
self.target_processes.iter().any(|pattern| {
// 支持简单的通配符匹配
if pattern.contains('*') {
// TODO: 实现更复杂的模式匹配
process_name.contains(&pattern.replace('*', ""))
} else {
process_name.eq_ignore_ascii_case(pattern)
}
})
}
/// 检查进程是否在排除列表中
pub fn is_excluded_process(&self, process_name: &str) -> bool {
self.excluded_processes.iter().any(|pattern| {
if pattern.contains('*') {
process_name.contains(&pattern.replace('*', ""))
} else {
process_name.eq_ignore_ascii_case(pattern)
}
})
}
/// 检查 URL 是否匹配拦截模式
pub fn matches_url_pattern(&self, url: &str) -> bool {
self.url_patterns.iter().any(|pattern| {
// 简单的模式匹配实现
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
url.starts_with(parts[0]) && url.ends_with(parts[1])
} else {
// 更复杂的模式匹配
url.contains(&pattern.replace('*', ""))
}
} else {
url.starts_with(pattern)
}
})
}
/// 验证配置的有效性
pub fn validate(&self) -> Result<(), String> {
if self.target_processes.is_empty() {
return Err("目标进程列表不能为空".to_string());
}
if self.url_patterns.is_empty() {
return Err("URL 模式列表不能为空".to_string());
}
if self.fingerprint_browser.enabled && self.fingerprint_browser.executable_path.is_empty() {
return Err("启用指纹浏览器时必须指定可执行文件路径".to_string());
}
if let Some(timeout) = self.temporary_disable_timeout {
if timeout == 0 {
return Err("临时禁用超时时间必须大于 0".to_string());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_target_process() {
let config = BrowserInterceptorConfig::default();
assert!(config.is_target_process("kiro"));
assert!(config.is_target_process("KIRO.EXE"));
assert!(config.is_target_process("cursor"));
assert!(!config.is_target_process("notepad"));
}
#[test]
fn test_is_excluded_process() {
let config = BrowserInterceptorConfig::default();
assert!(config.is_excluded_process("chrome.exe"));
assert!(config.is_excluded_process("FIREFOX.EXE"));
assert!(!config.is_excluded_process("kiro"));
}
#[test]
fn test_matches_url_pattern() {
let config = BrowserInterceptorConfig::default();
assert!(config.matches_url_pattern("https://accounts.google.com/oauth/authorize"));
assert!(config.matches_url_pattern("https://github.com/login/oauth"));
assert!(config.matches_url_pattern("https://auth.example.com/login"));
assert!(!config.matches_url_pattern("https://example.com/normal-page"));
}
#[test]
fn test_config_validation() {
let mut config = BrowserInterceptorConfig::default();
assert!(config.validate().is_ok());
config.target_processes.clear();
assert!(config.validate().is_err());
config = BrowserInterceptorConfig::default();
config.fingerprint_browser.enabled = true;
config.fingerprint_browser.executable_path = String::new();
assert!(config.validate().is_err());
}
}
@@ -0,0 +1,460 @@
use crate::browser_interceptor::{
BrowserInterceptorConfig, BrowserInterceptorError, InterceptedUrl, NotificationService, Result,
StateManager, UrlManager,
};
use std::sync::Arc;
use tokio::sync::RwLock;
/// 浏览器拦截器主结构
pub struct BrowserInterceptor {
config: Arc<RwLock<BrowserInterceptorConfig>>,
state_manager: Arc<StateManager>,
url_manager: Arc<UrlManager>,
notification_service: Arc<RwLock<NotificationService>>,
#[cfg(target_os = "windows")]
windows_interceptor: Option<crate::browser_interceptor::platform::windows::WindowsInterceptor>,
#[cfg(target_os = "macos")]
macos_interceptor: Option<crate::browser_interceptor::platform::macos::MacOSInterceptor>,
#[cfg(target_os = "linux")]
linux_interceptor: Option<crate::browser_interceptor::platform::linux::LinuxInterceptor>,
}
impl BrowserInterceptor {
/// 创建新的浏览器拦截器实例
pub fn new(config: BrowserInterceptorConfig) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
state_manager: Arc::new(StateManager::new()),
url_manager: Arc::new(UrlManager::new()),
notification_service: Arc::new(RwLock::new(NotificationService::new())),
#[cfg(target_os = "windows")]
windows_interceptor: None,
#[cfg(target_os = "macos")]
macos_interceptor: None,
#[cfg(target_os = "linux")]
linux_interceptor: None,
}
}
/// 启动拦截器
pub async fn start(&mut self) -> Result<()> {
tracing::info!("开始启动浏览器拦截器...");
let config = self.config.read().await;
if !config.enabled {
tracing::error!("拦截器配置中 enabled 为 false");
return Err(BrowserInterceptorError::InterceptorError(
"拦截器未启用".to_string(),
));
}
// 验证配置
tracing::info!("验证配置...");
config.validate().map_err(|e| {
tracing::error!("配置验证失败: {}", e);
BrowserInterceptorError::ConfigError(format!("配置验证失败: {}", e))
})?;
drop(config); // 释放读锁
// 启用状态管理器
tracing::info!("启用状态管理器...");
self.state_manager.enable_interceptor().await.map_err(|e| {
tracing::error!("启用状态管理器失败: {}", e);
e
})?;
// 启动平台特定的拦截器
tracing::info!("启动平台拦截器...");
self.start_platform_interceptor().await.map_err(|e| {
tracing::error!("启动平台拦截器失败: {}", e);
e
})?;
// 发送启用通知
tracing::info!("发送启用通知...");
let notification_service = self.notification_service.read().await;
notification_service.notify_interceptor_enabled().await?;
tracing::info!("浏览器拦截器已成功启动");
Ok(())
}
/// 停止拦截器
pub async fn stop(&mut self) -> Result<()> {
// 停止平台特定的拦截器
self.stop_platform_interceptor().await?;
// 禁用状态管理器
self.state_manager.disable_interceptor().await?;
// 发送禁用通知
let notification_service = self.notification_service.read().await;
notification_service.notify_interceptor_disabled().await?;
tracing::info!("浏览器拦截器已停止");
Ok(())
}
/// 恢复正常浏览器行为
pub async fn restore_normal_behavior(&mut self) -> Result<()> {
// 停止拦截
self.stop_platform_interceptor().await?;
// 恢复系统状态
self.state_manager.restore_normal_behavior().await?;
// 发送恢复通知
let notification_service = self.notification_service.read().await;
notification_service.notify_system_restored().await?;
tracing::info!("已恢复正常浏览器行为");
Ok(())
}
/// 临时禁用拦截器
pub async fn temporary_disable(&mut self, duration_seconds: u64) -> Result<()> {
self.state_manager
.temporary_disable(duration_seconds)
.await?;
// 临时停止平台拦截器
self.stop_platform_interceptor().await?;
tracing::info!("拦截器已临时禁用 {} 秒", duration_seconds);
Ok(())
}
/// 处理拦截到的浏览器启动请求
pub async fn handle_browser_launch(&self, url: String, source_process: String) -> Result<bool> {
let config = self.config.read().await;
// 检查是否应该拦截这个进程
if !self.should_intercept(&config, &source_process, &url) {
return Ok(false); // 不拦截,让浏览器正常启动
}
drop(config); // 释放读锁
// 添加到拦截列表
let url_id = self
.url_manager
.add_intercepted_url(url.clone(), source_process.clone())?;
// 更新拦截计数
self.state_manager.increment_intercept_count()?;
// 创建拦截的 URL 对象用于通知
let intercepted_url = InterceptedUrl::new(url, source_process);
// 发送通知
let notification_service = self.notification_service.read().await;
notification_service
.notify_url_intercepted(&intercepted_url)
.await?;
// 如果启用了自动复制到剪贴板
let config = self.config.read().await;
if config.auto_copy_to_clipboard {
self.copy_to_clipboard(&intercepted_url.url).await?;
self.url_manager.mark_as_copied(&url_id)?;
}
tracing::info!(
"已拦截浏览器启动: {} (来源: {})",
intercepted_url.url,
intercepted_url.source_process
);
Ok(true) // 已拦截
}
/// 获取当前状态
pub async fn get_state(&self) -> Result<crate::browser_interceptor::InterceptorState> {
self.state_manager.get_state()
}
/// 获取拦截的 URL 列表
pub async fn get_intercepted_urls(&self) -> Result<Vec<InterceptedUrl>> {
self.url_manager.get_intercepted_urls()
}
/// 获取历史记录
pub async fn get_history(&self, limit: Option<usize>) -> Result<Vec<InterceptedUrl>> {
self.url_manager.get_history(limit)
}
/// 复制 URL 到剪贴板
pub async fn copy_url_to_clipboard(&self, url_id: &str) -> Result<()> {
if let Some(intercepted_url) = self.url_manager.get_intercepted_url(url_id)? {
self.copy_to_clipboard(&intercepted_url.url).await?;
self.url_manager.mark_as_copied(url_id)?;
tracing::info!("URL {} 已复制到剪贴板", url_id);
}
Ok(())
}
/// 在指纹浏览器中打开 URL
pub async fn open_in_fingerprint_browser(&self, url_id: &str) -> Result<()> {
let config = self.config.read().await;
if !config.fingerprint_browser.enabled {
return Err(BrowserInterceptorError::InterceptorError(
"指纹浏览器未启用".to_string(),
));
}
if let Some(intercepted_url) = self.url_manager.get_intercepted_url(url_id)? {
self.launch_fingerprint_browser(&config.fingerprint_browser, &intercepted_url.url)
.await?;
self.url_manager.mark_as_opened(url_id)?;
tracing::info!("URL {} 已在指纹浏览器中打开", url_id);
}
Ok(())
}
/// 忽略指定的 URL
pub async fn dismiss_url(&self, url_id: &str) -> Result<()> {
self.url_manager.dismiss_url(url_id)?;
tracing::info!("URL {} 已被忽略", url_id);
Ok(())
}
/// 更新配置
pub async fn update_config(&self, new_config: BrowserInterceptorConfig) -> Result<()> {
// 验证新配置
new_config
.validate()
.map_err(|e| BrowserInterceptorError::ConfigError(format!("配置验证失败: {}", e)))?;
let mut config = self.config.write().await;
*config = new_config;
tracing::info!("浏览器拦截器配置已更新");
Ok(())
}
/// 检查是否应该拦截指定的进程和 URL
fn should_intercept(
&self,
config: &BrowserInterceptorConfig,
process_name: &str,
url: &str,
) -> bool {
// 检查是否在排除列表中
if config.is_excluded_process(process_name) {
return false;
}
// 检查是否是目标进程
if !config.is_target_process(process_name) {
return false;
}
// 检查 URL 是否匹配模式
config.matches_url_pattern(url)
}
/// 启动平台特定的拦截器
async fn start_platform_interceptor(&mut self) -> Result<()> {
#[cfg(target_os = "windows")]
{
// 创建 URL 处理器闭包
let url_manager = Arc::clone(&self.url_manager);
let state_manager = Arc::clone(&self.state_manager);
let url_handler = move |intercepted_url: InterceptedUrl| {
let url_manager = Arc::clone(&url_manager);
let state_manager = Arc::clone(&state_manager);
tokio::spawn(async move {
if let Err(e) = url_manager
.add_intercepted_url(intercepted_url.url, intercepted_url.source_process)
{
tracing::error!("添加拦截 URL 失败: {}", e);
}
if let Err(e) = state_manager.increment_intercept_count() {
tracing::error!("更新拦截计数失败: {}", e);
}
});
};
let mut interceptor =
crate::browser_interceptor::platform::windows::WindowsInterceptor::new(url_handler);
interceptor.start().await?;
self.windows_interceptor = Some(interceptor);
}
#[cfg(target_os = "macos")]
{
let url_manager = Arc::clone(&self.url_manager);
let state_manager = Arc::clone(&self.state_manager);
let url_handler = move |intercepted_url: InterceptedUrl| {
let url_manager = Arc::clone(&url_manager);
let state_manager = Arc::clone(&state_manager);
tokio::spawn(async move {
if let Err(e) = url_manager
.add_intercepted_url(intercepted_url.url, intercepted_url.source_process)
{
tracing::error!("添加拦截 URL 失败: {}", e);
}
if let Err(e) = state_manager.increment_intercept_count() {
tracing::error!("更新拦截计数失败: {}", e);
}
});
};
let mut interceptor =
crate::browser_interceptor::platform::macos::MacOSInterceptor::new(url_handler);
interceptor.start().await?;
self.macos_interceptor = Some(interceptor);
}
#[cfg(target_os = "linux")]
{
let url_manager = Arc::clone(&self.url_manager);
let state_manager = Arc::clone(&self.state_manager);
let url_handler = move |intercepted_url: InterceptedUrl| {
let url_manager = Arc::clone(&url_manager);
let state_manager = Arc::clone(&state_manager);
tokio::spawn(async move {
if let Err(e) = url_manager
.add_intercepted_url(intercepted_url.url, intercepted_url.source_process)
{
tracing::error!("添加拦截 URL 失败: {}", e);
}
if let Err(e) = state_manager.increment_intercept_count() {
tracing::error!("更新拦截计数失败: {}", e);
}
});
};
let mut interceptor =
crate::browser_interceptor::platform::linux::LinuxInterceptor::new(url_handler);
interceptor.start().await?;
self.linux_interceptor = Some(interceptor);
}
Ok(())
}
/// 停止平台特定的拦截器
async fn stop_platform_interceptor(&mut self) -> Result<()> {
#[cfg(target_os = "windows")]
{
if let Some(ref mut interceptor) = self.windows_interceptor {
interceptor.stop().await?;
}
}
#[cfg(target_os = "macos")]
{
if let Some(ref mut interceptor) = self.macos_interceptor {
interceptor.stop().await?;
}
}
#[cfg(target_os = "linux")]
{
if let Some(ref mut interceptor) = self.linux_interceptor {
interceptor.stop().await?;
}
}
Ok(())
}
/// 复制文本到剪贴板
async fn copy_to_clipboard(&self, text: &str) -> Result<()> {
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(text) {
return Err(BrowserInterceptorError::InterceptorError(format!(
"复制到剪贴板失败: {}",
e
)));
}
tracing::info!("已复制到剪贴板: {}", text);
Ok(())
}
Err(e) => Err(BrowserInterceptorError::InterceptorError(format!(
"创建剪贴板实例失败: {}",
e
))),
}
}
/// 启动指纹浏览器
async fn launch_fingerprint_browser(
&self,
browser_config: &crate::browser_interceptor::config::FingerprintBrowserConfig,
url: &str,
) -> Result<()> {
if browser_config.executable_path.is_empty() {
return Err(BrowserInterceptorError::ConfigError(
"指纹浏览器可执行文件路径未配置".to_string(),
));
}
// 构建启动命令
let mut command = std::process::Command::new(&browser_config.executable_path);
// 添加 URL 参数
command.arg(url);
// 添加额外的参数
for arg in &browser_config.additional_args {
command.arg(arg);
}
// 如果配置了配置文件路径
if !browser_config.profile_path.is_empty() {
command
.arg("--user-data-dir")
.arg(&browser_config.profile_path);
}
// 异步启动进程
match command.spawn() {
Ok(mut child) => {
// 在后台等待进程完成
tokio::spawn(async move {
match child.wait() {
Ok(status) => {
if status.success() {
tracing::info!("指纹浏览器启动成功");
} else {
tracing::error!("指纹浏览器退出异常: {}", status);
}
}
Err(e) => {
tracing::error!("等待指纹浏览器进程失败: {}", e);
}
}
});
tracing::info!(
"已启动指纹浏览器: {} -> {}",
browser_config.executable_path,
url
);
Ok(())
}
Err(e) => Err(BrowserInterceptorError::InterceptorError(format!(
"启动指纹浏览器失败: {}",
e
))),
}
}
}
impl Default for BrowserInterceptor {
fn default() -> Self {
Self::new(BrowserInterceptorConfig::default())
}
}
+57
View File
@@ -0,0 +1,57 @@
pub mod config;
pub mod interceptor;
pub mod notification_service;
pub mod state_manager;
pub mod url_manager;
#[cfg(target_os = "windows")]
pub mod platform {
pub mod windows;
}
#[cfg(target_os = "macos")]
pub mod platform {
pub mod macos;
}
#[cfg(target_os = "linux")]
pub mod platform {
pub mod linux;
}
// 重新导出主要类型和函数
pub use config::{BrowserInterceptorConfig, InterceptedUrl, InterceptorState};
pub use interceptor::BrowserInterceptor;
pub use notification_service::NotificationService;
pub use state_manager::StateManager;
pub use url_manager::UrlManager;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt;
/// 浏览器拦截器错误类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BrowserInterceptorError {
ConfigError(String),
InterceptorError(String),
StateError(String),
PlatformError(String),
NotificationError(String),
}
impl fmt::Display for BrowserInterceptorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BrowserInterceptorError::ConfigError(msg) => write!(f, "配置错误: {}", msg),
BrowserInterceptorError::InterceptorError(msg) => write!(f, "拦截器错误: {}", msg),
BrowserInterceptorError::StateError(msg) => write!(f, "状态管理错误: {}", msg),
BrowserInterceptorError::PlatformError(msg) => write!(f, "平台错误: {}", msg),
BrowserInterceptorError::NotificationError(msg) => write!(f, "通知错误: {}", msg),
}
}
}
impl Error for BrowserInterceptorError {}
pub type Result<T> = std::result::Result<T, BrowserInterceptorError>;
@@ -0,0 +1,307 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// 通知类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NotificationType {
UrlIntercepted,
InterceptorEnabled,
InterceptorDisabled,
SystemRestored,
Error,
}
/// 通知消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationMessage {
pub id: String,
pub notification_type: NotificationType,
pub title: String,
pub message: String,
pub url: Option<String>,
pub source_process: Option<String>,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub auto_dismiss_after: Option<Duration>,
}
impl NotificationMessage {
pub fn new(notification_type: NotificationType, title: String, message: String) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
notification_type,
title,
message,
url: None,
source_process: None,
timestamp: chrono::Utc::now(),
auto_dismiss_after: Some(Duration::from_secs(30)),
}
}
pub fn with_url(mut self, url: String) -> Self {
self.url = Some(url);
self
}
pub fn with_source_process(mut self, source_process: String) -> Self {
self.source_process = Some(source_process);
self
}
pub fn with_auto_dismiss(mut self, duration: Option<Duration>) -> Self {
self.auto_dismiss_after = duration;
self
}
}
/// 通知服务
pub struct NotificationService {
enabled: bool,
show_url_preview: bool,
}
impl NotificationService {
pub fn new() -> Self {
Self {
enabled: true,
show_url_preview: true,
}
}
/// 设置通知是否启用
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// 设置是否显示 URL 预览
pub fn set_show_url_preview(&mut self, show_preview: bool) {
self.show_url_preview = show_preview;
}
/// 发送 URL 拦截通知
pub async fn notify_url_intercepted(&self, intercepted_url: &InterceptedUrl) -> Result<()> {
if !self.enabled {
return Ok(());
}
let title = format!("已拦截来自 {} 的 URL", intercepted_url.source_process);
let message = if self.show_url_preview {
format!("URL: {}", self.truncate_url(&intercepted_url.url, 100))
} else {
"点击查看详情".to_string()
};
let notification =
NotificationMessage::new(NotificationType::UrlIntercepted, title, message)
.with_url(intercepted_url.url.clone())
.with_source_process(intercepted_url.source_process.clone());
self.send_notification(notification).await
}
/// 发送拦截器启用通知
pub async fn notify_interceptor_enabled(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
let notification = NotificationMessage::new(
NotificationType::InterceptorEnabled,
"浏览器拦截器已启用".to_string(),
"现在会拦截目标应用的浏览器启动请求".to_string(),
);
self.send_notification(notification).await
}
/// 发送拦截器禁用通知
pub async fn notify_interceptor_disabled(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
let notification = NotificationMessage::new(
NotificationType::InterceptorDisabled,
"浏览器拦截器已禁用".to_string(),
"应用将正常打开默认浏览器".to_string(),
);
self.send_notification(notification).await
}
/// 发送系统恢复通知
pub async fn notify_system_restored(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
let notification = NotificationMessage::new(
NotificationType::SystemRestored,
"系统已恢复正常".to_string(),
"浏览器行为已恢复到原始状态".to_string(),
);
self.send_notification(notification).await
}
/// 发送错误通知
pub async fn notify_error(&self, error_message: &str) -> Result<()> {
if !self.enabled {
return Ok(());
}
let notification = NotificationMessage::new(
NotificationType::Error,
"浏览器拦截器错误".to_string(),
error_message.to_string(),
)
.with_auto_dismiss(Some(Duration::from_secs(60))); // 错误通知显示更长时间
self.send_notification(notification).await
}
/// 发送通知的具体实现
async fn send_notification(&self, notification: NotificationMessage) -> Result<()> {
// 记录日志
tracing::info!(
"发送通知: {} - {}",
notification.title,
notification.message
);
// 发送系统通知
self.send_system_notification(&notification).await?;
// 发送到前端(通过事件系统)
self.send_frontend_notification(&notification).await?;
Ok(())
}
/// 发送系统通知
async fn send_system_notification(&self, notification: &NotificationMessage) -> Result<()> {
#[cfg(target_os = "windows")]
{
self.send_windows_notification(notification).await?;
}
#[cfg(target_os = "macos")]
{
self.send_macos_notification(notification).await?;
}
#[cfg(target_os = "linux")]
{
self.send_linux_notification(notification).await?;
}
Ok(())
}
/// 发送到前端
async fn send_frontend_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 通过 Tauri 事件系统发送到前端
// tauri::emit_all("browser-interceptor-notification", notification)
// .map_err(|e| BrowserInterceptorError::NotificationError(format!("发送前端通知失败: {}", e)))?;
tracing::debug!("前端通知已发送: {}", notification.id);
Ok(())
}
/// Windows 系统通知
#[cfg(target_os = "windows")]
async fn send_windows_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 Windows Toast 通知 API
// 可以使用 winrt-notification 或 windows-rs crate
tracing::debug!("Windows 通知: {}", notification.title);
Ok(())
}
/// macOS 系统通知
#[cfg(target_os = "macos")]
async fn send_macos_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 macOS 通知中心
// 可以使用 mac-notification-sys crate
tracing::debug!("macOS 通知: {}", notification.title);
Ok(())
}
/// Linux 系统通知
#[cfg(target_os = "linux")]
async fn send_linux_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 libnotify 或 D-Bus
// 可以使用 notify-rust crate
tracing::debug!("Linux 通知: {}", notification.title);
Ok(())
}
/// 截断 URL 以适应通知显示
fn truncate_url(&self, url: &str, max_length: usize) -> String {
if url.len() <= max_length {
url.to_string()
} else {
format!("{}...", &url[..max_length.saturating_sub(3)])
}
}
}
impl Default for NotificationService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_notification_message_creation() {
let notification = NotificationMessage::new(
NotificationType::UrlIntercepted,
"Test Title".to_string(),
"Test Message".to_string(),
)
.with_url("https://example.com".to_string())
.with_source_process("test_app".to_string());
assert_eq!(notification.title, "Test Title");
assert_eq!(notification.message, "Test Message");
assert_eq!(notification.url, Some("https://example.com".to_string()));
assert_eq!(notification.source_process, Some("test_app".to_string()));
assert!(notification.auto_dismiss_after.is_some());
}
#[test]
fn test_truncate_url() {
let service = NotificationService::new();
let short_url = "https://example.com";
assert_eq!(service.truncate_url(short_url, 100), short_url);
let long_url = "https://example.com/very/long/path/that/exceeds/the/maximum/length/limit";
let truncated = service.truncate_url(long_url, 20);
assert_eq!(truncated.len(), 20);
assert!(truncated.ends_with("..."));
}
#[tokio::test]
async fn test_notify_url_intercepted() {
let service = NotificationService::new();
let intercepted_url = InterceptedUrl {
id: "test-id".to_string(),
url: "https://accounts.google.com/oauth/authorize".to_string(),
source_process: "kiro".to_string(),
timestamp: Utc::now(),
copied: false,
opened_in_browser: false,
dismissed: false,
};
// 这个测试主要验证函数不会 panic
let result = service.notify_url_intercepted(&intercepted_url).await;
assert!(result.is_ok());
}
}
@@ -0,0 +1,71 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
/// Linux 平台的浏览器拦截器
pub struct LinuxInterceptor {
running: bool,
}
impl LinuxInterceptor {
pub fn new<F>(_url_handler: F) -> Self
where
F: Fn(InterceptedUrl) + Send + Sync + 'static,
{
Self { running: false }
}
/// 启动拦截
pub async fn start(&mut self) -> Result<()> {
// TODO: 实现 Linux 平台的浏览器拦截
// 可以使用 xdg-open 拦截或 D-Bus 监听
self.running = true;
tracing::info!("Linux 浏览器拦截器已启动 (占位符)");
Ok(())
}
/// 停止拦截
pub async fn stop(&mut self) -> Result<()> {
if !self.running {
return Ok(());
}
self.running = false;
tracing::info!("Linux 浏览器拦截器已停止");
Ok(())
}
/// 检查是否正在拦截
pub fn is_running(&self) -> bool {
self.running
}
/// 恢复系统默认设置
pub async fn restore_system_defaults(&self) -> Result<()> {
tracing::info!("Linux 系统默认设置已恢复");
Ok(())
}
/// 临时禁用拦截
pub async fn temporarily_disable(&mut self) -> Result<()> {
tracing::info!("Linux 拦截器已临时禁用");
Ok(())
}
/// 重新启用拦截
pub async fn re_enable(&mut self) -> Result<()> {
tracing::info!("Linux 拦截器已重新启用");
Ok(())
}
}
impl Drop for LinuxInterceptor {
fn drop(&mut self) {
if self.running {
tokio::runtime::Handle::try_current().map(|handle| {
handle.block_on(async {
let _ = self.stop().await;
let _ = self.restore_system_defaults().await;
})
});
}
}
}
@@ -0,0 +1,429 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
use once_cell::sync::Lazy;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// 全局 URL sender,用于从 deep-link 事件接收 URL(使用 Mutex 保证线程安全)
static GLOBAL_URL_SENDER: Lazy<Mutex<Option<mpsc::UnboundedSender<InterceptedUrl>>>> =
Lazy::new(|| Mutex::new(None));
/// macOS 平台的浏览器拦截器(基于设置默认浏览器 + Deep Link)
pub struct MacOSInterceptor {
running: bool,
url_sender: Option<mpsc::UnboundedSender<InterceptedUrl>>,
original_default_browser: Option<String>,
url_handler: Option<Arc<dyn Fn(InterceptedUrl) + Send + Sync + 'static>>,
}
impl MacOSInterceptor {
pub fn new<F>(url_handler: F) -> Self
where
F: Fn(InterceptedUrl) + Send + Sync + 'static,
{
let (tx, mut rx) = mpsc::unbounded_channel();
let handler = Arc::new(url_handler);
let handler_clone = handler.clone();
// 启动后台任务处理拦截的 URL
tokio::spawn(async move {
while let Some(intercepted_url) = rx.recv().await {
handler_clone(intercepted_url);
}
});
Self {
running: false,
url_sender: Some(tx),
original_default_browser: None,
url_handler: Some(handler),
}
}
/// 启动拦截
pub async fn start(&mut self) -> Result<()> {
if self.running {
tracing::info!("macOS 拦截器已在运行中,跳过启动");
return Ok(());
}
tracing::info!("正在启动 macOS 浏览器拦截器...");
// 1. 保存当前默认浏览器
self.original_default_browser = self.get_default_browser().await;
tracing::info!("当前默认浏览器: {:?}", self.original_default_browser);
// 2. 设置全局 URL sender
if let Some(ref sender) = self.url_sender {
if let Ok(mut global_sender) = GLOBAL_URL_SENDER.lock() {
*global_sender = Some(sender.clone());
}
}
// 3. 将 ProxyCast 设置为默认浏览器
self.set_as_default_browser().await?;
self.running = true;
tracing::info!("macOS 浏览器拦截器已启动");
tracing::info!("提示:现在所有 http/https URL 打开请求都会被 ProxyCast 拦截");
Ok(())
}
/// 获取当前默认浏览器的 Bundle ID
async fn get_default_browser(&self) -> Option<String> {
// 使用 Swift 调用 LSCopyDefaultHandlerForURLScheme 获取真实的默认浏览器
let swift_code = r#"
import Foundation
import CoreServices
if let handler = LSCopyDefaultHandlerForURLScheme("https" as CFString) {
print(handler.takeRetainedValue() as String)
} else if let handler = LSCopyDefaultHandlerForURLScheme("http" as CFString) {
print(handler.takeRetainedValue() as String)
} else {
print("")
}
"#;
let output = Command::new("swift")
.args(["-e", swift_code])
.output()
.ok()?;
if output.status.success() {
let bundle_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !bundle_id.is_empty() && !bundle_id.contains("proxycast") {
tracing::info!("检测到当前默认浏览器: {}", bundle_id);
return Some(bundle_id);
}
// 如果当前是 ProxyCast,说明之前已设置过,需要检测用户常用的浏览器
if bundle_id.contains("proxycast") {
tracing::info!("当前默认浏览器是 ProxyCast,尝试检测用户常用浏览器");
return self.detect_installed_browser().await;
}
}
// 备选方法:使用 duti 查询
let duti_output = Command::new("duti").args(["-x", "https"]).output().ok();
if let Some(output) = duti_output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// duti -x 输出格式:第三行是 bundle id
for line in stdout.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("com.") || trimmed.starts_with("org."))
&& !trimmed.contains("proxycast")
{
tracing::info!("通过 duti 检测到默认浏览器: {}", trimmed);
return Some(trimmed.to_string());
}
}
}
}
// 最后尝试检测已安装的浏览器,优先返回 Chrome
self.detect_installed_browser().await
}
/// 将 ProxyCast 设置为默认浏览器
async fn set_as_default_browser(&self) -> Result<()> {
tracing::info!("正在将 ProxyCast 设置为默认浏览器...");
// 使用 Swift 代码通过 Launch Services API 设置默认处理程序
// 由于 Rust 直接调用 Launch Services 较复杂,这里使用 osascript 辅助
// 方法 1: 使用 duti 工具(如果已安装)
let duti_result = Command::new("duti")
.args(["-s", "com.proxycast.app", "http", "all"])
.output();
if let Ok(output) = duti_result {
if output.status.success() {
let _ = Command::new("duti")
.args(["-s", "com.proxycast.app", "https", "all"])
.output();
tracing::info!("已通过 duti 设置默认浏览器");
return Ok(());
}
}
// 方法 2: 使用 Swift 脚本
let swift_code = r#"
import Foundation
import CoreServices
let bundleId = "com.proxycast.app" as CFString
// 设置 HTTP handler
LSSetDefaultHandlerForURLScheme("http" as CFString, bundleId)
// 设置 HTTPS handler
LSSetDefaultHandlerForURLScheme("https" as CFString, bundleId)
print("OK")
"#;
let output = Command::new("swift")
.args(["-e", swift_code])
.output()
.map_err(|e| {
BrowserInterceptorError::PlatformError(format!("执行 Swift 脚本失败: {}", e))
})?;
if output.status.success() {
tracing::info!("已通过 Launch Services API 设置默认浏览器");
return Ok(());
}
// 方法 3: 提示用户手动设置
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("自动设置默认浏览器失败: {}", stderr);
tracing::info!("请手动在系统设置中将 ProxyCast 设置为默认浏览器");
// 打开系统设置
let _ = Command::new("open")
.args(["x-apple.systempreferences:com.apple.preference.general"])
.output();
Ok(())
}
/// 恢复原来的默认浏览器
async fn restore_default_browser(&self) -> Result<()> {
// 获取要恢复的浏览器
let browser_id = match &self.original_default_browser {
Some(id) if !id.contains("proxycast") => id.clone(),
_ => {
// 如果没有记录原始浏览器,尝试检测已安装的浏览器
self.detect_installed_browser()
.await
.unwrap_or_else(|| "com.google.Chrome".to_string())
}
};
tracing::info!("正在恢复默认浏览器为: {}", browser_id);
// 使用 duti
let duti_result = Command::new("duti")
.args(["-s", &browser_id, "http", "all"])
.output();
if let Ok(output) = duti_result {
if output.status.success() {
let _ = Command::new("duti")
.args(["-s", &browser_id, "https", "all"])
.output();
tracing::info!("已通过 duti 恢复默认浏览器为: {}", browser_id);
return Ok(());
}
}
// 使用 Swift
let swift_code = format!(
r#"
import Foundation
import CoreServices
let bundleId = "{}" as CFString
LSSetDefaultHandlerForURLScheme("http" as CFString, bundleId)
LSSetDefaultHandlerForURLScheme("https" as CFString, bundleId)
print("OK")
"#,
browser_id
);
let output = Command::new("swift").args(["-e", &swift_code]).output();
if let Ok(out) = output {
if out.status.success() {
tracing::info!("已通过 Swift 恢复默认浏览器为: {}", browser_id);
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::warn!("Swift 恢复默认浏览器失败: {}", stderr);
}
}
Ok(())
}
/// 检测已安装的浏览器(用于恢复时选择)
async fn detect_installed_browser(&self) -> Option<String> {
// 按优先级排序的浏览器列表
let browsers = [
("com.google.Chrome", "/Applications/Google Chrome.app"),
("org.mozilla.firefox", "/Applications/Firefox.app"),
("com.microsoft.edgemac", "/Applications/Microsoft Edge.app"),
("com.brave.Browser", "/Applications/Brave Browser.app"),
("com.apple.Safari", "/Applications/Safari.app"),
];
for (bundle_id, app_path) in &browsers {
// 直接检查应用是否存在
if std::path::Path::new(app_path).exists() {
tracing::info!("检测到已安装的浏览器: {} ({})", bundle_id, app_path);
return Some(bundle_id.to_string());
}
}
// Safari 总是存在
Some("com.apple.Safari".to_string())
}
/// 停止拦截
pub async fn stop(&mut self) -> Result<()> {
if !self.running {
return Ok(());
}
tracing::info!("正在停止 macOS 浏览器拦截器...");
// 清除全局 URL sender
if let Ok(mut global_sender) = GLOBAL_URL_SENDER.lock() {
*global_sender = None;
}
// 恢复默认浏览器
self.restore_default_browser().await?;
self.running = false;
tracing::info!("macOS 浏览器拦截器已停止");
Ok(())
}
/// 检查是否正在拦截
pub fn is_running(&self) -> bool {
self.running
}
/// 获取当前监听的端口(不再使用,保留接口兼容性)
pub fn get_port(&self) -> Option<u16> {
None
}
/// 恢复系统默认设置
pub async fn restore_system_defaults(&self) -> Result<()> {
self.restore_default_browser().await
}
/// 临时禁用拦截
pub async fn temporarily_disable(&mut self) -> Result<()> {
if !self.running {
return Ok(());
}
tracing::info!("正在临时禁用 macOS 浏览器拦截器...");
// 临时恢复默认浏览器
self.restore_default_browser().await?;
tracing::info!("macOS 浏览器拦截器已临时禁用");
Ok(())
}
/// 重新启用拦截
pub async fn re_enable(&mut self) -> Result<()> {
if !self.running {
return Err(BrowserInterceptorError::InterceptorError(
"拦截器未运行,无法重新启用".to_string(),
));
}
tracing::info!("正在重新启用 macOS 浏览器拦截器...");
// 重新设置为默认浏览器
self.set_as_default_browser().await?;
tracing::info!("macOS 浏览器拦截器已重新启用");
Ok(())
}
}
impl Drop for MacOSInterceptor {
fn drop(&mut self) {
if self.running {
tracing::info!("macOS 浏览器拦截器资源已清理");
// 注意:在 drop 中无法使用 async,恢复操作在 stop() 中完成
}
}
}
/// 处理从 deep-link 接收到的 URL(由 lib.rs 中的事件监听器调用)
pub fn handle_deep_link_url(url: String) {
tracing::info!("收到 deep-link URL: {}", url);
// 检查是否是 http/https URL
if !url.starts_with("http://") && !url.starts_with("https://") {
tracing::debug!("忽略非 HTTP URL: {}", url);
return;
}
// 尝试识别来源进程(macOS 上较难获取,使用默认值)
let source_process = detect_source_process(&url);
let intercepted_url = InterceptedUrl::new(url, source_process);
// 发送到处理器
if let Ok(global_sender) = GLOBAL_URL_SENDER.lock() {
if let Some(ref sender) = *global_sender {
match sender.send(intercepted_url) {
Ok(_) => tracing::debug!("URL 已发送到处理器"),
Err(e) => tracing::error!("发送 URL 到处理器失败: {:?}", e),
}
} else {
tracing::warn!("拦截器未运行,忽略 URL");
}
} else {
tracing::error!("无法获取 URL sender 锁");
}
}
/// 尝试检测 URL 的来源进程
fn detect_source_process(url: &str) -> String {
// 基于 URL 特征推测来源
if url.contains("kiro") || url.contains("amazon") || url.contains("aws") {
return "Kiro".to_string();
}
if url.contains("cursor") || url.contains("anysphere") {
return "Cursor".to_string();
}
if url.contains("vscode") || url.contains("microsoft") || url.contains("visualstudio") {
return "VSCode".to_string();
}
if url.contains("claude") || url.contains("anthropic") {
return "Claude App".to_string();
}
if url.contains("github") {
return "GitHub App".to_string();
}
if url.contains("google") || url.contains("accounts.google") {
return "OAuth Request".to_string();
}
// 尝试获取前台应用
if let Ok(output) = Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to get the name of first process whose frontmost is true",
])
.output()
{
if output.status.success() {
let app_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !app_name.is_empty() && app_name != "ProxyCast" {
return app_name;
}
}
}
"Unknown App".to_string()
}
/// 检查拦截器是否有活跃的 URL sender
pub fn is_interceptor_active() -> bool {
GLOBAL_URL_SENDER
.lock()
.map(|sender| sender.is_some())
.unwrap_or(false)
}
@@ -0,0 +1,454 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
use chrono::Utc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use uuid::Uuid;
#[cfg(windows)]
use {
std::ffi::{OsStr, OsString},
std::iter::once,
std::os::windows::ffi::OsStrExt,
std::ptr,
windows::{
core::*, Win32::Foundation::*, Win32::System::Registry::*, Win32::System::Threading::*,
Win32::UI::Shell::*,
},
};
/// Windows 平台的浏览器拦截器
pub struct WindowsInterceptor {
running: bool,
original_browser: Option<String>,
temp_exe_path: Option<String>,
intercepted_urls_handler: Arc<Mutex<Box<dyn Fn(InterceptedUrl) + Send + Sync>>>,
monitor_thread: Option<std::thread::JoinHandle<()>>,
}
#[cfg(windows)]
impl WindowsInterceptor {
pub fn new<F>(url_handler: F) -> Self
where
F: Fn(InterceptedUrl) + Send + Sync + 'static,
{
Self {
running: false,
original_browser: None,
temp_exe_path: None,
intercepted_urls_handler: Arc::new(Mutex::new(Box::new(url_handler))),
monitor_thread: None,
}
}
/// 启动拦截
pub async fn start(&mut self) -> Result<()> {
if self.running {
return Err(BrowserInterceptorError::AlreadyRunning);
}
// 备份当前默认浏览器设置
self.backup_default_browser().await?;
// 创建临时拦截程序
self.create_interceptor_executable().await?;
// 设置我们的程序为默认浏览器
self.set_as_default_browser().await?;
// 启动进程监控
self.start_process_monitoring().await?;
self.running = true;
tracing::info!("Windows 浏览器拦截器已启动");
Ok(())
}
/// 停止拦截
pub async fn stop(&mut self) -> Result<()> {
if !self.running {
return Ok(());
}
// 停止进程监控
if let Some(handle) = self.monitor_thread.take() {
// 发送停止信号,等待线程结束
handle.join().ok();
}
// 恢复原始默认浏览器
self.restore_default_browser().await?;
// 清理临时文件
self.cleanup_temp_files().await?;
self.running = false;
tracing::info!("Windows 浏览器拦截器已停止");
Ok(())
}
/// 备份当前默认浏览器设置
async fn backup_default_browser(&mut self) -> Result<()> {
unsafe {
let key_path = to_wide_chars(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let mut buffer_size = buffer.len() * 2;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size as *mut u32),
);
RegCloseKey(key);
if result.is_ok() {
let prog_id = String::from_utf16_lossy(&buffer[..buffer_size / 2 - 1]);
self.original_browser = Some(prog_id);
tracing::info!(
"备份原始默认浏览器: {}",
self.original_browser.as_ref().unwrap()
);
}
}
}
Ok(())
}
/// 创建临时的拦截器可执行文件
async fn create_interceptor_executable(&mut self) -> Result<()> {
// 创建一个简单的拦截器程序,用于接收 URL 参数
let temp_dir = std::env::temp_dir();
let exe_path = temp_dir.join("proxycast_browser_interceptor.exe");
// 创建拦截器脚本内容(批处理脚本)
let bat_content = format!(
r#"@echo off
echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
"#,
temp_dir.to_string_lossy()
);
let bat_path = temp_dir.join("proxycast_browser_interceptor.bat");
std::fs::write(&bat_path, bat_content)?;
// 创建一个简单的可执行文件包装器
// 这里我们使用批处理文件,在真实环境中应该编译一个专用的小程序
self.temp_exe_path = Some(bat_path.to_string_lossy().to_string());
Ok(())
}
/// 设置我们的程序为默认浏览器
async fn set_as_default_browser(&self) -> Result<()> {
if let Some(exe_path) = &self.temp_exe_path {
unsafe {
// 注册我们的程序为HTTP处理器
let prog_id = "ProxyCastInterceptor";
let key_path = format!(r"Software\Classes\{}", prog_id);
self.set_registry_string(&key_path, "", "ProxyCast Browser Interceptor")
.await?;
let command_path = format!(r"{}\shell\open\command", key_path);
let command_value = format!(r#"{} "%1""#, exe_path);
self.set_registry_string(&command_path, "", &command_value)
.await?;
// 设置为HTTP协议的默认处理器
let http_key = r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
self.set_registry_string(http_key, "ProgId", prog_id)
.await?;
// 设置为HTTPS协议的默认处理器
let https_key = r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
self.set_registry_string(https_key, "ProgId", prog_id)
.await?;
tracing::info!("已设置 ProxyCast 为临时默认浏览器");
}
}
Ok(())
}
/// 设置注册表字符串值
async fn set_registry_string(
&self,
key_path: &str,
value_name: &str,
value_data: &str,
) -> Result<()> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
None,
);
if result.is_ok() {
let value_name_wide = to_wide_chars(value_name);
let value_data_wide = to_wide_chars(value_data);
RegSetValueExW(
key,
PCWSTR::from_raw(value_name_wide.as_ptr()),
0,
REG_SZ,
Some(value_data_wide.as_ptr() as *const u8),
value_data_wide.len() * 2,
);
RegCloseKey(key);
}
}
Ok(())
}
/// 启动进程监控
async fn start_process_monitoring(&mut self) -> Result<()> {
let handler = Arc::clone(&self.intercepted_urls_handler);
let temp_dir = std::env::temp_dir();
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
let handle = thread::spawn(move || {
loop {
// 检查拦截日志文件
if let Ok(content) = std::fs::read_to_string(&log_file) {
for line in content.lines() {
if line.starts_with("URL被拦截: ") {
let url = line.replace("URL被拦截: ", "");
if !url.is_empty() && should_intercept_url(&url) {
let intercepted = InterceptedUrl {
id: Uuid::new_v4().to_string(),
url: url.clone(),
source_process: "Unknown".to_string(),
timestamp: Utc::now(),
copied: false,
opened_in_browser: false,
dismissed: false,
};
// 调用处理器
if let Ok(handler_guard) = handler.lock() {
handler_guard(intercepted);
}
}
}
}
// 清空日志文件避免重复处理
std::fs::write(&log_file, "").ok();
}
thread::sleep(Duration::from_millis(1000));
}
});
self.monitor_thread = Some(handle);
Ok(())
}
/// 恢复原始默认浏览器
async fn restore_default_browser(&self) -> Result<()> {
if let Some(original_browser) = &self.original_browser {
// 恢复HTTP协议处理器
let http_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
self.set_registry_string(http_key, "ProgId", original_browser)
.await?;
// 恢复HTTPS协议处理器
let https_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
self.set_registry_string(https_key, "ProgId", original_browser)
.await?;
tracing::info!("已恢复原始默认浏览器: {}", original_browser);
}
Ok(())
}
/// 清理临时文件
async fn cleanup_temp_files(&self) -> Result<()> {
if let Some(exe_path) = &self.temp_exe_path {
std::fs::remove_file(exe_path).ok();
}
let temp_dir = std::env::temp_dir();
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
std::fs::remove_file(log_file).ok();
Ok(())
}
/// 检查是否正在拦截
pub fn is_running(&self) -> bool {
self.running
}
/// 恢复系统默认设置
pub async fn restore_system_defaults(&self) -> Result<()> {
// 恢复默认浏览器设置
self.restore_default_browser().await?;
tracing::info!("系统默认设置已恢复");
Ok(())
}
/// 临时禁用拦截
pub async fn temporarily_disable(&mut self) -> Result<()> {
if let Some(original_browser) = &self.original_browser {
self.restore_default_browser().await?;
tracing::info!("拦截器已临时禁用");
}
Ok(())
}
/// 重新启用拦截
pub async fn re_enable(&mut self) -> Result<()> {
self.set_as_default_browser().await?;
tracing::info!("拦截器已重新启用");
Ok(())
}
}
#[cfg(not(windows))]
impl WindowsInterceptor {
pub fn new<F>(_url_handler: F) -> Self
where
F: Fn(InterceptedUrl) + Send + Sync + 'static,
{
Self {
running: false,
original_browser: None,
temp_exe_path: None,
intercepted_urls_handler: Arc::new(Mutex::new(Box::new(|_| {}))),
monitor_thread: None,
}
}
pub async fn start(&mut self) -> Result<()> {
Err(BrowserInterceptorError::UnsupportedPlatform(
"Windows interceptor only supports Windows platform".to_string(),
))
}
pub async fn stop(&mut self) -> Result<()> {
Ok(())
}
pub fn is_running(&self) -> bool {
false
}
pub async fn restore_system_defaults(&self) -> Result<()> {
Ok(())
}
pub async fn temporarily_disable(&mut self) -> Result<()> {
Ok(())
}
pub async fn re_enable(&mut self) -> Result<()> {
Ok(())
}
}
impl Drop for WindowsInterceptor {
fn drop(&mut self) {
if self.running {
// 在析构时恢复系统默认设置
tokio::runtime::Handle::try_current().map(|handle| {
handle.block_on(async {
let _ = self.stop().await;
let _ = self.restore_system_defaults().await;
})
});
}
}
}
// Windows 特定的辅助函数
fn to_wide_chars(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(once(0)).collect()
}
/// 检查进程是否为目标应用
pub fn is_target_process(process_name: &str) -> bool {
let target_processes = [
"kiro",
"kiro.exe",
"cursor",
"cursor.exe",
"code",
"code.exe",
];
target_processes
.iter()
.any(|&target| process_name.to_lowercase().contains(&target.to_lowercase()))
}
/// 检查 URL 是否匹配拦截模式
pub fn should_intercept_url(url: &str) -> bool {
let patterns = [
"https://auth.",
"https://accounts.google.com",
"https://github.com/login",
"https://login.microsoftonline.com",
"/oauth/",
"/auth/",
"localhost:8080/auth", // OAuth 回调地址
];
patterns.iter().any(|&pattern| url.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_target_process() {
assert!(is_target_process("kiro.exe"));
assert!(is_target_process("cursor"));
assert!(is_target_process("code.exe"));
assert!(!is_target_process("notepad.exe"));
}
#[test]
fn test_should_intercept_url() {
assert!(should_intercept_url("https://accounts.google.com/oauth"));
assert!(should_intercept_url("https://github.com/login/oauth"));
assert!(should_intercept_url("localhost:8080/auth/callback"));
assert!(!should_intercept_url("https://example.com"));
}
}
@@ -0,0 +1,504 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptorState, Result};
use chrono::Utc;
use std::sync::{Arc, RwLock};
use tokio::time::{Duration, Instant};
#[cfg(windows)]
use {
std::ffi::OsStr,
std::iter::once,
std::os::windows::ffi::OsStrExt,
windows::{core::*, Win32::Foundation::*, Win32::System::Registry::*},
};
/// 状态管理器,负责管理拦截器的状态和恢复机制
pub struct StateManager {
state: Arc<RwLock<InterceptorState>>,
original_system_state: Arc<RwLock<Option<SystemState>>>,
temporary_disable_timer: Arc<RwLock<Option<Instant>>>,
}
/// 系统原始状态备份
#[derive(Debug, Clone)]
pub struct SystemState {
pub default_browser: Option<String>,
pub registry_backup: std::collections::HashMap<String, String>,
pub environment_backup: std::collections::HashMap<String, String>,
pub timestamp: chrono::DateTime<Utc>,
}
impl StateManager {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(InterceptorState::default())),
original_system_state: Arc::new(RwLock::new(None)),
temporary_disable_timer: Arc::new(RwLock::new(None)),
}
}
/// 获取当前状态
pub fn get_state(&self) -> Result<InterceptorState> {
self.state
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取状态失败: {}", e)))
.map(|state| state.clone())
}
/// 启用拦截器
pub async fn enable_interceptor(&self) -> Result<()> {
// 备份系统状态
self.backup_system_state().await?;
// 更新状态
{
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
state.enabled = true;
state.can_restore = true;
state.last_activity = Some(Utc::now());
}
tracing::info!("浏览器拦截器已启用");
Ok(())
}
/// 禁用拦截器
pub async fn disable_interceptor(&self) -> Result<()> {
{
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
state.enabled = false;
state.active_hooks.clear();
state.last_activity = Some(Utc::now());
}
tracing::info!("浏览器拦截器已禁用");
Ok(())
}
/// 临时禁用拦截器
pub async fn temporary_disable(&self, duration_seconds: u64) -> Result<()> {
self.disable_interceptor().await?;
// 设置定时器
{
let mut timer = self.temporary_disable_timer.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("设置定时器失败: {}", e))
})?;
*timer = Some(Instant::now() + Duration::from_secs(duration_seconds));
}
// 启动后台任务来重新启用
let state_manager = self.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(duration_seconds)).await;
if let Err(e) = state_manager.enable_interceptor().await {
tracing::error!("自动重新启用拦截器失败: {}", e);
} else {
tracing::info!("拦截器已自动重新启用");
}
});
tracing::info!("拦截器已临时禁用 {} 秒", duration_seconds);
Ok(())
}
/// 恢复正常浏览器行为
pub async fn restore_normal_behavior(&self) -> Result<()> {
// 先禁用拦截器
self.disable_interceptor().await?;
// 恢复系统状态
self.restore_system_state().await?;
{
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
state.can_restore = false;
}
tracing::info!("已恢复正常浏览器行为");
Ok(())
}
/// 增加拦截计数
pub fn increment_intercept_count(&self) -> Result<()> {
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
state.intercepted_count += 1;
state.last_activity = Some(Utc::now());
Ok(())
}
/// 添加活跃钩子
pub fn add_active_hook(&self, hook_name: String) -> Result<()> {
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
if !state.active_hooks.contains(&hook_name) {
state.active_hooks.push(hook_name);
}
Ok(())
}
/// 移除活跃钩子
pub fn remove_active_hook(&self, hook_name: &str) -> Result<()> {
let mut state = self
.state
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("写入状态失败: {}", e)))?;
state.active_hooks.retain(|h| h != hook_name);
Ok(())
}
/// 备份系统状态
async fn backup_system_state(&self) -> Result<()> {
let system_state = SystemState {
default_browser: self.get_default_browser().await?,
registry_backup: self.backup_registry_keys().await?,
environment_backup: self.backup_environment_variables().await?,
timestamp: Utc::now(),
};
{
let mut backup = self.original_system_state.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("备份系统状态失败: {}", e))
})?;
*backup = Some(system_state);
}
tracing::info!("系统状态已备份");
Ok(())
}
/// 恢复系统状态
async fn restore_system_state(&self) -> Result<()> {
let backup = {
let backup_guard = self.original_system_state.read().map_err(|e| {
BrowserInterceptorError::StateError(format!("读取备份状态失败: {}", e))
})?;
backup_guard.clone()
};
if let Some(system_state) = backup {
// 恢复默认浏览器
if let Some(default_browser) = &system_state.default_browser {
self.restore_default_browser(default_browser).await?;
}
// 恢复注册表项
self.restore_registry_keys(&system_state.registry_backup)
.await?;
// 恢复环境变量
self.restore_environment_variables(&system_state.environment_backup)
.await?;
tracing::info!("系统状态已恢复到 {} 的备份", system_state.timestamp);
} else {
tracing::warn!("没有找到系统状态备份");
}
Ok(())
}
/// 获取默认浏览器(平台特定实现)
async fn get_default_browser(&self) -> Result<Option<String>> {
#[cfg(target_os = "windows")]
{
self.get_default_browser_windows().await
}
#[cfg(target_os = "macos")]
{
// macOS 实现
// TODO: 使用 Launch Services API
Ok(None)
}
#[cfg(target_os = "linux")]
{
// Linux 实现
// TODO: 读取 xdg-settings
Ok(None)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Ok(None)
}
}
/// Windows 平台获取默认浏览器
#[cfg(target_os = "windows")]
async fn get_default_browser_windows(&self) -> Result<Option<String>> {
unsafe {
let key_path = to_wide_chars(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let mut buffer_size = buffer.len() * 2;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size as *mut u32),
);
RegCloseKey(key);
if result.is_ok() {
let prog_id = String::from_utf16_lossy(&buffer[..buffer_size / 2 - 1]);
return Ok(Some(prog_id));
}
}
}
Ok(None)
}
/// 备份注册表项(Windows 特定)
async fn backup_registry_keys(&self) -> Result<std::collections::HashMap<String, String>> {
let mut backup = std::collections::HashMap::new();
#[cfg(target_os = "windows")]
{
// 备份 HTTP 和 HTTPS 协议关联
let keys_to_backup = [
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
];
for key_path in &keys_to_backup {
if let Ok(value) = self.read_registry_value(key_path, "ProgId").await {
backup.insert(key_path.to_string(), value);
}
}
}
Ok(backup)
}
/// 读取注册表值
#[cfg(target_os = "windows")]
async fn read_registry_value(&self, key_path: &str, value_name: &str) -> Result<String> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let mut buffer_size = buffer.len() * 2;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars(value_name).as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size as *mut u32),
);
RegCloseKey(key);
if result.is_ok() {
let value = String::from_utf16_lossy(&buffer[..buffer_size / 2 - 1]);
return Ok(value);
}
}
}
Err(BrowserInterceptorError::StateError(format!(
"无法读取注册表项: {}",
key_path
)))
}
/// 备份环境变量
async fn backup_environment_variables(
&self,
) -> Result<std::collections::HashMap<String, String>> {
let mut backup = std::collections::HashMap::new();
// 备份可能影响浏览器启动的环境变量
if let Ok(browser) = std::env::var("BROWSER") {
backup.insert("BROWSER".to_string(), browser);
}
Ok(backup)
}
/// 恢复默认浏览器
async fn restore_default_browser(&self, browser: &str) -> Result<()> {
#[cfg(target_os = "windows")]
{
self.restore_default_browser_windows(browser).await
}
#[cfg(not(target_os = "windows"))]
{
tracing::info!("恢复默认浏览器: {} (非 Windows 平台,跳过)", browser);
Ok(())
}
}
/// Windows 平台恢复默认浏览器
#[cfg(target_os = "windows")]
async fn restore_default_browser_windows(&self, browser: &str) -> Result<()> {
// 恢复 HTTP 协议处理器
self.write_registry_value(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
"ProgId",
browser,
)
.await?;
// 恢复 HTTPS 协议处理器
self.write_registry_value(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
"ProgId",
browser,
)
.await?;
tracing::info!("已恢复默认浏览器为: {}", browser);
Ok(())
}
/// 恢复注册表项
async fn restore_registry_keys(
&self,
backup: &std::collections::HashMap<String, String>,
) -> Result<()> {
#[cfg(target_os = "windows")]
{
for (key_path, value) in backup {
if let Err(e) = self.write_registry_value(key_path, "ProgId", value).await {
tracing::error!("恢复注册表项失败 {}: {}", key_path, e);
}
}
}
Ok(())
}
/// 写入注册表值
#[cfg(target_os = "windows")]
async fn write_registry_value(
&self,
key_path: &str,
value_name: &str,
value_data: &str,
) -> Result<()> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
None,
);
if result.is_ok() {
let value_name_wide = to_wide_chars(value_name);
let value_data_wide = to_wide_chars(value_data);
RegSetValueExW(
key,
PCWSTR::from_raw(value_name_wide.as_ptr()),
0,
REG_SZ,
Some(value_data_wide.as_ptr() as *const u8),
value_data_wide.len() * 2,
);
RegCloseKey(key);
return Ok(());
}
}
Err(BrowserInterceptorError::StateError(format!(
"无法写入注册表项: {}",
key_path
)))
}
/// 恢复环境变量
async fn restore_environment_variables(
&self,
backup: &std::collections::HashMap<String, String>,
) -> Result<()> {
for (key, value) in backup {
std::env::set_var(key, value);
}
Ok(())
}
}
impl Clone for StateManager {
fn clone(&self) -> Self {
Self {
state: Arc::clone(&self.state),
original_system_state: Arc::clone(&self.original_system_state),
temporary_disable_timer: Arc::clone(&self.temporary_disable_timer),
}
}
}
impl Default for StateManager {
fn default() -> Self {
Self::new()
}
}
/// Windows 辅助函数:将字符串转换为宽字符
#[cfg(target_os = "windows")]
fn to_wide_chars(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(once(0)).collect()
}
@@ -0,0 +1,600 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, RwLock};
/// URL 管理器,负责管理被拦截的 URL
pub struct UrlManager {
intercepted_urls: Arc<RwLock<HashMap<String, InterceptedUrl>>>,
history: Arc<RwLock<Vec<InterceptedUrl>>>,
max_history_size: usize,
storage_path: Option<String>,
}
impl UrlManager {
pub fn new() -> Self {
Self {
intercepted_urls: Arc::new(RwLock::new(HashMap::new())),
history: Arc::new(RwLock::new(Vec::new())),
max_history_size: 1000, // 最多保存 1000 条历史记录
storage_path: None,
}
}
/// 创建带持久化存储的 URL 管理器
pub fn with_storage<P: AsRef<Path>>(storage_path: P) -> Result<Self> {
let storage_path = storage_path.as_ref().to_string_lossy().to_string();
let mut manager = Self {
intercepted_urls: Arc::new(RwLock::new(HashMap::new())),
history: Arc::new(RwLock::new(Vec::new())),
max_history_size: 1000,
storage_path: Some(storage_path.clone()),
};
// 从文件加载历史记录
manager.load_from_storage()?;
Ok(manager)
}
/// 添加被拦截的 URL
pub fn add_intercepted_url(&self, url: String, source_process: String) -> Result<String> {
let intercepted_url = InterceptedUrl::new(url, source_process);
let id = intercepted_url.id.clone();
let url_for_log = intercepted_url.url.clone();
let process_for_log = intercepted_url.source_process.clone();
// 添加到当前拦截列表
{
let mut urls = self.intercepted_urls.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("添加拦截 URL 失败: {}", e))
})?;
urls.insert(id.clone(), intercepted_url.clone());
}
// 添加到历史记录
{
let mut history = self.history.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("添加历史记录失败: {}", e))
})?;
history.push(intercepted_url);
// 限制历史记录大小
if history.len() > self.max_history_size {
history.remove(0);
}
}
// 自动保存
let _ = self.auto_save();
tracing::info!(
"已添加拦截 URL: {} (来源: {})",
url_for_log,
process_for_log
);
Ok(id)
}
/// 获取所有当前拦截的 URL
pub fn get_intercepted_urls(&self) -> Result<Vec<InterceptedUrl>> {
let urls = self.intercepted_urls.read().map_err(|e| {
BrowserInterceptorError::StateError(format!("读取拦截 URL 失败: {}", e))
})?;
let mut result: Vec<InterceptedUrl> = urls.values().cloned().collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); // 按时间倒序排列
Ok(result)
}
/// 获取指定 ID 的拦截 URL
pub fn get_intercepted_url(&self, id: &str) -> Result<Option<InterceptedUrl>> {
let urls = self.intercepted_urls.read().map_err(|e| {
BrowserInterceptorError::StateError(format!("读取拦截 URL 失败: {}", e))
})?;
Ok(urls.get(id).cloned())
}
/// 标记 URL 为已复制
pub fn mark_as_copied(&self, id: &str) -> Result<()> {
let mut urls = self.intercepted_urls.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("更新拦截 URL 失败: {}", e))
})?;
if let Some(url) = urls.get_mut(id) {
url.copied = true;
tracing::info!("URL {} 已标记为已复制", id);
}
Ok(())
}
/// 标记 URL 为已在浏览器中打开
pub fn mark_as_opened(&self, id: &str) -> Result<()> {
let mut urls = self.intercepted_urls.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("更新拦截 URL 失败: {}", e))
})?;
if let Some(url) = urls.get_mut(id) {
url.opened_in_browser = true;
tracing::info!("URL {} 已标记为已在浏览器中打开", id);
}
Ok(())
}
/// 忽略(移除)指定的 URL
pub fn dismiss_url(&self, id: &str) -> Result<()> {
let mut urls = self.intercepted_urls.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("移除拦截 URL 失败: {}", e))
})?;
if let Some(mut url) = urls.remove(id) {
url.dismissed = true;
// 更新历史记录中的状态
let mut history = self.history.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("更新历史记录失败: {}", e))
})?;
if let Some(history_url) = history.iter_mut().find(|u| u.id == id) {
history_url.dismissed = true;
}
tracing::info!("URL {} 已被忽略", id);
}
Ok(())
}
/// 清除所有当前拦截的 URL
pub fn clear_intercepted_urls(&self) -> Result<()> {
let mut urls = self.intercepted_urls.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("清除拦截 URL 失败: {}", e))
})?;
let count = urls.len();
urls.clear();
tracing::info!("已清除 {} 个拦截的 URL", count);
Ok(())
}
/// 获取历史记录
pub fn get_history(&self, limit: Option<usize>) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut result = history.clone();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); // 按时间倒序排列
if let Some(limit) = limit {
result.truncate(limit);
}
Ok(result)
}
/// 搜索历史记录
pub fn search_history(&self, query: &str, limit: Option<usize>) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("搜索历史记录失败: {}", e)))?;
let query_lower = query.to_lowercase();
let mut result: Vec<InterceptedUrl> = history
.iter()
.filter(|url| {
url.url.to_lowercase().contains(&query_lower)
|| url.source_process.to_lowercase().contains(&query_lower)
})
.cloned()
.collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = limit {
result.truncate(limit);
}
Ok(result)
}
/// 按日期范围获取历史记录
pub fn get_history_by_date_range(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut result: Vec<InterceptedUrl> = history
.iter()
.filter(|url| url.timestamp >= start && url.timestamp <= end)
.cloned()
.collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(result)
}
/// 获取统计信息
pub fn get_statistics(&self) -> Result<UrlStatistics> {
let urls = self.intercepted_urls.read().map_err(|e| {
BrowserInterceptorError::StateError(format!("读取拦截 URL 失败: {}", e))
})?;
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let current_count = urls.len();
let total_intercepted = history.len();
let copied_count = history.iter().filter(|u| u.copied).count();
let opened_count = history.iter().filter(|u| u.opened_in_browser).count();
let dismissed_count = history.iter().filter(|u| u.dismissed).count();
// 统计来源进程
let mut process_stats = HashMap::new();
for url in history.iter() {
*process_stats.entry(url.source_process.clone()).or_insert(0) += 1;
}
Ok(UrlStatistics {
current_intercepted: current_count,
total_intercepted,
copied_count,
opened_count,
dismissed_count,
process_stats,
})
}
/// 清理过期的历史记录
pub fn cleanup_old_history(&self, days: u32) -> Result<usize> {
let cutoff_date = Utc::now() - chrono::Duration::days(days as i64);
let mut history = self
.history
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("清理历史记录失败: {}", e)))?;
let original_len = history.len();
history.retain(|url| url.timestamp > cutoff_date);
let removed_count = original_len - history.len();
if removed_count > 0 {
tracing::info!("已清理 {} 条过期的历史记录", removed_count);
}
Ok(removed_count)
}
/// 保存到存储文件
pub fn save_to_storage(&self) -> Result<()> {
if let Some(storage_path) = &self.storage_path {
let history = self.history.read().map_err(|e| {
BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e))
})?;
let storage_data = UrlStorageData {
history: history.clone(),
max_history_size: self.max_history_size,
saved_at: Utc::now(),
};
let json_data = serde_json::to_string_pretty(&storage_data).map_err(|e| {
BrowserInterceptorError::StateError(format!("序列化数据失败: {}", e))
})?;
// 确保目录存在
if let Some(parent) = Path::new(storage_path).parent() {
fs::create_dir_all(parent).map_err(|e| {
BrowserInterceptorError::StateError(format!("创建目录失败: {}", e))
})?;
}
fs::write(storage_path, json_data)
.map_err(|e| BrowserInterceptorError::StateError(format!("写入文件失败: {}", e)))?;
tracing::info!("已保存历史记录到: {}", storage_path);
}
Ok(())
}
/// 从存储文件加载
pub fn load_from_storage(&mut self) -> Result<()> {
if let Some(storage_path) = &self.storage_path {
if Path::new(storage_path).exists() {
let json_data = fs::read_to_string(storage_path).map_err(|e| {
BrowserInterceptorError::StateError(format!("读取文件失败: {}", e))
})?;
let storage_data: UrlStorageData =
serde_json::from_str(&json_data).map_err(|e| {
BrowserInterceptorError::StateError(format!("反序列化数据失败: {}", e))
})?;
{
let mut history = self.history.write().map_err(|e| {
BrowserInterceptorError::StateError(format!("写入历史记录失败: {}", e))
})?;
*history = storage_data.history;
}
self.max_history_size = storage_data.max_history_size;
tracing::info!("已从 {} 加载历史记录", storage_path);
}
}
Ok(())
}
/// 导出历史记录为 JSON
pub fn export_history_json(&self, export_path: &str) -> Result<()> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let export_data = UrlExportData {
urls: history.clone(),
exported_at: Utc::now(),
total_count: history.len(),
};
let json_data = serde_json::to_string_pretty(&export_data).map_err(|e| {
BrowserInterceptorError::StateError(format!("序列化导出数据失败: {}", e))
})?;
// 确保目录存在
if let Some(parent) = Path::new(export_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
}
fs::write(export_path, json_data)
.map_err(|e| BrowserInterceptorError::StateError(format!("写入导出文件失败: {}", e)))?;
tracing::info!("已导出历史记录到: {}", export_path);
Ok(())
}
/// 导出历史记录为 CSV
pub fn export_history_csv(&self, export_path: &str) -> Result<()> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut csv_content =
String::from("ID,URL,Source Process,Timestamp,Copied,Opened in Browser,Dismissed\n");
for url in history.iter() {
csv_content.push_str(&format!(
"{},{},{},{},{},{},{}\n",
url.id,
url.url.replace(",", "%2C"), // 转义逗号
url.source_process,
url.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
url.copied,
url.opened_in_browser,
url.dismissed
));
}
// 确保目录存在
if let Some(parent) = Path::new(export_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
}
fs::write(export_path, csv_content).map_err(|e| {
BrowserInterceptorError::StateError(format!("写入 CSV 文件失败: {}", e))
})?;
tracing::info!("已导出历史记录到 CSV: {}", export_path);
Ok(())
}
/// 验证 URL 是否有效
pub fn validate_url(url: &str) -> bool {
// 基本的 URL 验证
if url.is_empty() {
return false;
}
// 检查是否包含必要的协议
if !url.starts_with("http://") && !url.starts_with("https://") {
return false;
}
// 使用 url crate 进行更严格的验证
if let Ok(_) = url::Url::parse(url) {
return true;
}
false
}
/// 按来源进程过滤历史记录
pub fn get_history_by_process(
&self,
process: &str,
limit: Option<usize>,
) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut result: Vec<InterceptedUrl> = history
.iter()
.filter(|url| url.source_process.eq_ignore_ascii_case(process))
.cloned()
.collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = limit {
result.truncate(limit);
}
Ok(result)
}
/// 获取所有唯一的来源进程列表
pub fn get_unique_processes(&self) -> Result<Vec<String>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut processes: Vec<String> = history
.iter()
.map(|url| url.source_process.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
processes.sort();
Ok(processes)
}
/// 自动保存(如果已配置存储路径)
fn auto_save(&self) -> Result<()> {
if self.storage_path.is_some() {
self.save_to_storage()?;
}
Ok(())
}
}
/// 存储数据结构
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UrlStorageData {
history: Vec<InterceptedUrl>,
max_history_size: usize,
saved_at: DateTime<Utc>,
}
/// 导出数据结构
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UrlExportData {
urls: Vec<InterceptedUrl>,
exported_at: DateTime<Utc>,
total_count: usize,
}
/// URL 统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlStatistics {
pub current_intercepted: usize,
pub total_intercepted: usize,
pub copied_count: usize,
pub opened_count: usize,
pub dismissed_count: usize,
pub process_stats: HashMap<String, usize>,
}
impl Default for UrlManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_get_intercepted_url() {
let manager = UrlManager::new();
let id = manager
.add_intercepted_url(
"https://accounts.google.com/oauth/authorize".to_string(),
"kiro".to_string(),
)
.unwrap();
let urls = manager.get_intercepted_urls().unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].id, id);
assert_eq!(urls[0].url, "https://accounts.google.com/oauth/authorize");
assert_eq!(urls[0].source_process, "kiro");
}
#[test]
fn test_mark_as_copied() {
let manager = UrlManager::new();
let id = manager
.add_intercepted_url("https://test.com".to_string(), "test".to_string())
.unwrap();
manager.mark_as_copied(&id).unwrap();
let url = manager.get_intercepted_url(&id).unwrap().unwrap();
assert!(url.copied);
}
#[test]
fn test_dismiss_url() {
let manager = UrlManager::new();
let id = manager
.add_intercepted_url("https://test.com".to_string(), "test".to_string())
.unwrap();
manager.dismiss_url(&id).unwrap();
let urls = manager.get_intercepted_urls().unwrap();
assert_eq!(urls.len(), 0);
// 但历史记录中应该还存在
let history = manager.get_history(None).unwrap();
assert_eq!(history.len(), 1);
assert!(history[0].dismissed);
}
#[test]
fn test_search_history() {
let manager = UrlManager::new();
manager
.add_intercepted_url(
"https://accounts.google.com/oauth".to_string(),
"kiro".to_string(),
)
.unwrap();
manager
.add_intercepted_url("https://github.com/login".to_string(), "cursor".to_string())
.unwrap();
let results = manager.search_history("google", None).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].url.contains("google"));
let results = manager.search_history("cursor", None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].source_process, "cursor");
}
}
@@ -0,0 +1,360 @@
use crate::browser_interceptor::{
BrowserInterceptor, BrowserInterceptorConfig, InterceptedUrl, InterceptorState,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::sync::RwLock;
/// 浏览器拦截器状态封装
pub struct BrowserInterceptorState(pub Arc<RwLock<Option<BrowserInterceptor>>>);
impl Default for BrowserInterceptorState {
fn default() -> Self {
Self(Arc::new(RwLock::new(None)))
}
}
/// 获取拦截器状态
#[tauri::command]
pub async fn get_browser_interceptor_state(
state: State<'_, BrowserInterceptorState>,
) -> Result<Option<InterceptorState>, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_state().await {
Ok(state) => Ok(Some(state)),
Err(e) => Err(format!("获取拦截器状态失败: {}", e)),
}
} else {
Ok(None)
}
}
/// 启动浏览器拦截器
#[tauri::command]
pub async fn start_browser_interceptor(
state: State<'_, BrowserInterceptorState>,
config: BrowserInterceptorConfig,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
// 如果已经有拦截器在运行,先停止它
if let Some(existing_interceptor) = interceptor_guard.as_mut() {
if let Err(e) = existing_interceptor.stop().await {
tracing::warn!("停止现有拦截器时出错: {}", e);
}
}
// 创建新的拦截器
let mut interceptor = BrowserInterceptor::new(config);
match interceptor.start().await {
Ok(_) => {
*interceptor_guard = Some(interceptor);
Ok("浏览器拦截器已启动".to_string())
}
Err(e) => Err(format!("启动浏览器拦截器失败: {}", e)),
}
}
/// 停止浏览器拦截器
#[tauri::command]
pub async fn stop_browser_interceptor(
state: State<'_, BrowserInterceptorState>,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.stop().await {
Ok(_) => {
*interceptor_guard = None;
Ok("浏览器拦截器已停止".to_string())
}
Err(e) => Err(format!("停止浏览器拦截器失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 恢复正常浏览器行为
#[tauri::command]
pub async fn restore_normal_browser_behavior(
state: State<'_, BrowserInterceptorState>,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.restore_normal_behavior().await {
Ok(_) => Ok("已恢复正常浏览器行为".to_string()),
Err(e) => Err(format!("恢复正常浏览器行为失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 临时禁用拦截器
#[tauri::command]
pub async fn temporary_disable_interceptor(
state: State<'_, BrowserInterceptorState>,
duration_seconds: u64,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.temporary_disable(duration_seconds).await {
Ok(_) => Ok(format!("拦截器已临时禁用 {} 秒", duration_seconds)),
Err(e) => Err(format!("临时禁用拦截器失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 获取拦截的 URL 列表
#[tauri::command]
pub async fn get_intercepted_urls(
state: State<'_, BrowserInterceptorState>,
) -> Result<Vec<InterceptedUrl>, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_intercepted_urls().await {
Ok(urls) => Ok(urls),
Err(e) => Err(format!("获取拦截 URL 失败: {}", e)),
}
} else {
Ok(Vec::new())
}
}
/// 获取历史记录
#[tauri::command]
pub async fn get_interceptor_history(
state: State<'_, BrowserInterceptorState>,
limit: Option<usize>,
) -> Result<Vec<InterceptedUrl>, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_history(limit).await {
Ok(history) => Ok(history),
Err(e) => Err(format!("获取历史记录失败: {}", e)),
}
} else {
Ok(Vec::new())
}
}
/// 复制 URL 到剪贴板
#[tauri::command]
pub async fn copy_intercepted_url_to_clipboard(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.copy_url_to_clipboard(&url_id).await {
Ok(_) => Ok("URL 已复制到剪贴板".to_string()),
Err(e) => Err(format!("复制 URL 失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 在指纹浏览器中打开 URL
#[tauri::command]
pub async fn open_url_in_fingerprint_browser(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.open_in_fingerprint_browser(&url_id).await {
Ok(_) => Ok("URL 已在指纹浏览器中打开".to_string()),
Err(e) => Err(format!("在指纹浏览器中打开 URL 失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 忽略指定的 URL
#[tauri::command]
pub async fn dismiss_intercepted_url(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.dismiss_url(&url_id).await {
Ok(_) => Ok("URL 已忽略".to_string()),
Err(e) => Err(format!("忽略 URL 失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 更新拦截器配置
#[tauri::command]
pub async fn update_browser_interceptor_config(
state: State<'_, BrowserInterceptorState>,
config: BrowserInterceptorConfig,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.update_config(config).await {
Ok(_) => Ok("拦截器配置已更新".to_string()),
Err(e) => Err(format!("更新拦截器配置失败: {}", e)),
}
} else {
Err("拦截器未运行".to_string())
}
}
/// 获取默认配置
#[tauri::command]
pub async fn get_default_browser_interceptor_config() -> Result<BrowserInterceptorConfig, String> {
Ok(BrowserInterceptorConfig::default())
}
/// 验证配置
#[tauri::command]
pub async fn validate_browser_interceptor_config(
config: BrowserInterceptorConfig,
) -> Result<String, String> {
match config.validate() {
Ok(_) => Ok("配置验证通过".to_string()),
Err(e) => Err(format!("配置验证失败: {}", e)),
}
}
/// 检查拦截器是否正在运行
#[tauri::command]
pub async fn is_browser_interceptor_running(
state: State<'_, BrowserInterceptorState>,
) -> Result<bool, String> {
let interceptor_guard = state.0.read().await;
Ok(interceptor_guard.is_some())
}
/// 获取拦截器统计信息
#[derive(Debug, Serialize, Deserialize)]
pub struct InterceptorStatistics {
pub total_intercepted: usize,
pub current_intercepted: usize,
pub copied_count: usize,
pub opened_count: usize,
pub dismissed_count: usize,
}
#[tauri::command]
pub async fn get_browser_interceptor_statistics(
state: State<'_, BrowserInterceptorState>,
) -> Result<InterceptorStatistics, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
// 获取当前拦截的 URL
let current_urls = interceptor
.get_intercepted_urls()
.await
.map_err(|e| format!("获取当前 URL 失败: {}", e))?;
// 获取历史记录
let history = interceptor
.get_history(None)
.await
.map_err(|e| format!("获取历史记录失败: {}", e))?;
let copied_count = history.iter().filter(|u| u.copied).count();
let opened_count = history.iter().filter(|u| u.opened_in_browser).count();
let dismissed_count = history.iter().filter(|u| u.dismissed).count();
Ok(InterceptorStatistics {
total_intercepted: history.len(),
current_intercepted: current_urls.len(),
copied_count,
opened_count,
dismissed_count,
})
} else {
Ok(InterceptorStatistics {
total_intercepted: 0,
current_intercepted: 0,
copied_count: 0,
opened_count: 0,
dismissed_count: 0,
})
}
}
/// 通知相关结构体
#[derive(Debug, Serialize, Deserialize)]
pub struct NotificationPayload {
pub title: String,
pub body: String,
pub icon: Option<String>,
}
/// 显示系统通知
#[tauri::command]
pub async fn show_notification(
_app: AppHandle,
title: String,
body: String,
_icon: Option<String>,
) -> Result<String, String> {
// 简化实现,返回成功但实际功能待实现
tracing::info!("显示通知: {} - {}", title, body);
Ok("通知已记录到日志".to_string())
}
/// 显示URL拦截通知
#[tauri::command]
pub async fn show_url_intercept_notification(
app: AppHandle,
url: String,
source_process: String,
) -> Result<String, String> {
let title = "🔐 拦截到新的URL".to_string();
let body = format!("来自 {}: {}", source_process, truncate_url(&url, 60));
show_notification(app, title, body, Some("icon".to_string())).await
}
/// 显示状态变更通知
#[tauri::command]
pub async fn show_status_notification(
app: AppHandle,
message: String,
notification_type: String,
) -> Result<String, String> {
let (icon, title) = match notification_type.as_str() {
"success" => ("✅", "操作成功"),
"warning" => ("⚠️", "警告"),
"error" => ("❌", "错误"),
_ => ("ℹ️", "信息"),
};
let title = format!("{} {}", icon, title);
show_notification(app, title, message, None).await
}
/// 截断URL用于通知显示
fn truncate_url(url: &str, max_length: usize) -> String {
if url.len() <= max_length {
url.to_string()
} else {
format!("{}...", &url[0..max_length])
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod auto_fix_cmd;
pub mod browser_interceptor_cmd;
pub mod config_cmd;
pub mod flow_monitor_cmd;
pub mod injection_cmd;
+75 -1
View File
@@ -1,3 +1,4 @@
pub mod browser_interceptor;
mod commands;
mod config;
mod converter;
@@ -27,6 +28,7 @@ use std::sync::Arc;
use tauri::{Manager, Runtime};
use tokio::sync::RwLock;
use commands::browser_interceptor_cmd::BrowserInterceptorState;
use commands::flow_monitor_cmd::{
BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState,
FlowMonitorState, FlowQueryServiceState, FlowReplayerState, QuickFilterManagerState,
@@ -1633,6 +1635,9 @@ pub fn run() {
));
let batch_operations_state = BatchOperationsState(batch_operations);
// Initialize BrowserInterceptorState
let browser_interceptor_state = BrowserInterceptorState::default();
// FlowQueryService 需要 file_store,如果没有则创建一个临时的
let flow_query_service_state = if let Some(file_store) = flow_file_store {
let query_service = FlowQueryService::new(flow_monitor.memory_store(), file_store);
@@ -1668,13 +1673,46 @@ pub fn run() {
let flow_monitor_clone = flow_monitor.clone();
let flow_interceptor_clone = flow_interceptor.clone();
tauri::Builder::default()
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimized"]),
))
// 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
// 处理传入的 URL 参数
for arg in args.iter().skip(1) {
// 跳过第一个参数(程序路径)
if arg.starts_with("http://") || arg.starts_with("https://") {
tracing::info!("[单实例] 收到 URL: {}", arg);
#[cfg(target_os = "macos")]
{
crate::browser_interceptor::platform::macos::handle_deep_link_url(
arg.clone(),
);
}
}
}
// 将窗口带到前台
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}));
// 添加 Deep Link 插件(用于浏览器拦截)
#[cfg(target_os = "macos")]
{
builder = builder.plugin(tauri_plugin_deep_link::init());
}
builder
.manage(state)
.manage(logs)
.manage(db)
@@ -1695,6 +1733,7 @@ pub fn run() {
.manage(bookmark_manager_state)
.manage(enhanced_stats_service_state)
.manage(batch_operations_state)
.manage(browser_interceptor_state)
.on_window_event(move |window, event| {
// 处理窗口关闭事件
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
@@ -1721,6 +1760,21 @@ pub fn run() {
}
})
.setup(move |app| {
// 设置 deep-link 事件监听(用于浏览器拦截)
#[cfg(target_os = "macos")]
{
use tauri_plugin_deep_link::DeepLinkExt;
let _listener_id = app.deep_link().on_open_url(|event| {
for url in event.urls() {
tracing::info!("[Deep Link] 收到 URL: {}", url);
crate::browser_interceptor::platform::macos::handle_deep_link_url(
url.to_string(),
);
}
});
tracing::info!("[启动] Deep Link 事件监听已设置");
}
// 初始化托盘管理器
// Requirements 1.4: 应用启动时显示停止状态图标
match TrayManager::new(app.handle()) {
@@ -2239,6 +2293,26 @@ pub fn run() {
commands::window_cmd::set_window_size_by_option,
commands::window_cmd::toggle_fullscreen,
commands::window_cmd::is_fullscreen,
// Browser Interceptor commands
commands::browser_interceptor_cmd::get_browser_interceptor_state,
commands::browser_interceptor_cmd::start_browser_interceptor,
commands::browser_interceptor_cmd::stop_browser_interceptor,
commands::browser_interceptor_cmd::restore_normal_browser_behavior,
commands::browser_interceptor_cmd::temporary_disable_interceptor,
commands::browser_interceptor_cmd::get_intercepted_urls,
commands::browser_interceptor_cmd::get_interceptor_history,
commands::browser_interceptor_cmd::copy_intercepted_url_to_clipboard,
commands::browser_interceptor_cmd::open_url_in_fingerprint_browser,
commands::browser_interceptor_cmd::dismiss_intercepted_url,
commands::browser_interceptor_cmd::update_browser_interceptor_config,
commands::browser_interceptor_cmd::get_default_browser_interceptor_config,
commands::browser_interceptor_cmd::validate_browser_interceptor_config,
commands::browser_interceptor_cmd::is_browser_interceptor_running,
commands::browser_interceptor_cmd::get_browser_interceptor_statistics,
// Browser Interceptor notification commands
commands::browser_interceptor_cmd::show_notification,
commands::browser_interceptor_cmd::show_url_intercept_notification,
commands::browser_interceptor_cmd::show_status_notification,
// Auto fix commands
commands::auto_fix_cmd::auto_fix_configuration,
// Network commands
+9 -1
View File
@@ -136,7 +136,15 @@ fn test_management_auth_rate_limit_after_failures() {
let rt = tokio::runtime::Runtime::new().unwrap();
// 使用唯一的 IP 地址避免测试间干扰
let client_ip = format!("203.0.113.{}", std::process::id() % 256);
// 使用时间戳和进程ID组合来确保唯一性
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let client_ip = format!(
"203.0.113.{}",
(unique_id ^ std::process::id() as u64) % 256
);
let addr: SocketAddr = format!("{}:12345", client_ip).parse().unwrap();
for _ in 0..5 {
+62 -4
View File
@@ -2,6 +2,51 @@ use crate::models::{AppType, Provider};
use serde_json::{json, Value};
use std::path::PathBuf;
/// 原子写入 JSON 文件,防止配置损坏
/// 参考 cc-switch 的实现:使用临时文件 + 重命名的原子操作
fn write_json_file_atomic(
path: &std::path::Path,
value: &Value,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::fs;
use std::io::Write;
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// 创建临时文件
let temp_path = path.with_extension("tmp");
// 写入临时文件
let content = serde_json::to_string_pretty(value)?;
let mut temp_file = fs::File::create(&temp_path)?;
temp_file.write_all(content.as_bytes())?;
temp_file.flush()?;
drop(temp_file); // 确保文件句柄被释放
// 验证 JSON 格式正确性
let verify_content = fs::read_to_string(&temp_path)?;
let _: Value = serde_json::from_str(&verify_content)?; // 验证解析
// 原子性重命名
fs::rename(&temp_path, path)?;
tracing::info!("Successfully wrote config file: {}", path.display());
Ok(())
}
/// 创建配置文件的备份
fn create_backup(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if path.exists() {
let backup_path = path.with_extension("bak");
std::fs::copy(path, &backup_path)?;
tracing::info!("Created backup: {}", backup_path.display());
}
Ok(())
}
/// Get the configuration file path for an app type
#[allow(dead_code)]
pub fn get_app_config_path(app_type: &AppType) -> Option<PathBuf> {
@@ -64,6 +109,11 @@ fn sync_claude_settings(
let claude_dir = home.join(".claude");
let config_path = claude_dir.join("settings.json");
tracing::info!("开始同步 Claude 配置: {}", provider.name);
// 创建备份(如果文件存在)
create_backup(&config_path)?;
// Ensure .claude directory exists
if !claude_dir.exists() {
std::fs::create_dir_all(&claude_dir)?;
@@ -72,7 +122,13 @@ fn sync_claude_settings(
// Read existing settings to preserve other fields
let mut settings: Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::warn!("配置文件格式有误,使用默认配置: {}", e);
json!({})
}
}
} else {
json!({})
};
@@ -93,20 +149,22 @@ fn sync_claude_settings(
if let Some(target_env) = settings_obj.get_mut("env").and_then(|v| v.as_object_mut()) {
for (key, value) in env_obj {
target_env.insert(key.clone(), value.clone());
tracing::debug!("设置环境变量: {} = [MASKED]", key);
}
}
} else {
// If settings_config is the full settings object, use it directly
settings = provider.settings_config.clone();
tracing::debug!("使用完整配置对象");
}
// 清理冲突的认证环境变量
clean_claude_auth_conflict(&mut settings);
// Write settings
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&config_path, content)?;
// 使用原子写入
write_json_file_atomic(&config_path, &settings)?;
tracing::info!("Claude 配置同步完成: {}", config_path.display());
Ok(())
}
+77 -21
View File
@@ -86,42 +86,98 @@ impl SwitchService {
}
pub fn switch_provider(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
use tracing::{error, info, warn};
info!("开始切换 {} 配置到 provider: {}", app_type, id);
let conn = db.lock().map_err(|e| e.to_string())?;
// Get target provider
let target_provider = ProviderDao::get_by_id(&conn, app_type, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Provider not found: {id}"))?;
.map_err(|e| {
error!("查找目标 provider 失败: {}", e);
e.to_string()
})?
.ok_or_else(|| {
error!("目标 provider 不存在: {}", id);
format!("Provider not found: {id}")
})?;
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let app_type_enum = app_type.parse::<AppType>().map_err(|e| {
error!("无效的 app_type: {} - {}", app_type, e);
e.to_string()
})?;
// Skip backfill and sync for ProxyCast
// 获取当前 provider(用于回填和回滚)
let current_provider = if app_type_enum != AppType::ProxyCast {
ProviderDao::get_current(&conn, app_type).map_err(|e| {
error!("获取当前 provider 失败: {}", e);
e.to_string()
})?
} else {
None
};
// 实施事务保护:先尝试同步,再更新数据库
if app_type_enum != AppType::ProxyCast {
// Backfill: Read current live config and save to current provider
if let Some(current_provider) =
ProviderDao::get_current(&conn, app_type).map_err(|e| e.to_string())?
{
// Only backfill if switching to a different provider
if current_provider.id != id {
if let Ok(live_settings) = live_sync::read_live_settings(&app_type_enum) {
// Update current provider with live settings
let mut updated_provider = current_provider.clone();
updated_provider.settings_config = live_settings;
let _ = ProviderDao::update(&conn, &updated_provider);
// Step 1: Backfill - 回填当前配置
if let Some(ref current) = current_provider {
if current.id != id {
info!("回填当前配置: {}", current.name);
match live_sync::read_live_settings(&app_type_enum) {
Ok(live_settings) => {
let mut updated_provider = current.clone();
updated_provider.settings_config = live_settings;
if let Err(e) = ProviderDao::update(&conn, &updated_provider) {
warn!("回填配置失败,但继续执行: {}", e);
} else {
info!("回填配置完成");
}
}
Err(e) => {
warn!("读取当前配置失败,跳过回填: {}", e);
}
}
}
}
// Step 2: 尝试同步新配置(在更新数据库前验证)
info!("验证目标配置可同步性");
if let Err(sync_error) = live_sync::sync_to_live(&app_type_enum, &target_provider) {
error!("配置同步失败: {}", sync_error);
// 尝试恢复原配置(如果有)
if let Some(ref current) = current_provider {
warn!("尝试恢复原配置: {}", current.name);
if let Err(restore_error) = live_sync::sync_to_live(&app_type_enum, current) {
error!("恢复原配置失败: {}", restore_error);
return Err(format!("切换失败且无法恢复原配置: {}", sync_error));
}
}
return Err(format!("配置同步失败: {}", sync_error));
}
}
// Set new current provider
ProviderDao::set_current(&conn, app_type, id).map_err(|e| e.to_string())?;
// Step 3: 更新数据库(同步成功后)
info!("更新数据库中的当前 provider");
if let Err(db_error) = ProviderDao::set_current(&conn, app_type, id) {
error!("数据库更新失败: {}", db_error);
// Sync target provider to live config
if app_type_enum != AppType::ProxyCast {
live_sync::sync_to_live(&app_type_enum, &target_provider)
.map_err(|e| format!("Failed to sync: {e}"))?;
// 如果数据库更新失败,尝试恢复原配置文件
if app_type_enum != AppType::ProxyCast {
if let Some(ref current) = current_provider {
warn!("数据库更新失败,尝试恢复原配置文件");
if let Err(restore_error) = live_sync::sync_to_live(&app_type_enum, current) {
error!("恢复配置文件失败: {}", restore_error);
}
}
}
return Err(db_error.to_string());
}
info!("配置切换成功: {} -> {}", app_type, target_provider.name);
Ok(())
}
+14 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.18.0",
"version": "0.19.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -37,11 +37,23 @@
"resources": [
"icons/tray/*",
"../scripts/playwright-login/**/*"
]
],
"macOS": {
"entitlements": null,
"exceptionDomain": null,
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
}
},
"plugins": {
"shell": {
"open": true
},
"deep-link": {
"desktop": {
"schemes": ["proxycast"]
}
}
}
}
+8
View File
@@ -5,6 +5,8 @@ import { ApiServerPage } from "./components/api-server/ApiServerPage";
import { ProviderPoolPage } from "./components/provider-pool";
import { ConfigManagementPage } from "./components/config/ConfigManagementPage";
import { FlowMonitorPage } from "./pages";
import { ToolsPage } from "./components/tools/ToolsPage";
import { BrowserInterceptorTool } from "./components/tools/browser-interceptor/BrowserInterceptorTool";
import { flowEventManager } from "./lib/flowEventManager";
type Page =
@@ -12,6 +14,8 @@ type Page =
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "settings";
function App() {
@@ -33,6 +37,10 @@ function App() {
return <ApiServerPage />;
case "flow-monitor":
return <FlowMonitorPage />;
case "tools":
return <ToolsPage onNavigate={setCurrentPage} />;
case "browser-interceptor":
return <BrowserInterceptorTool onNavigate={setCurrentPage} />;
case "settings":
return <SettingsPage />;
default:
+11 -1
View File
@@ -1,4 +1,11 @@
import { Settings, Globe, Database, FileCode, Activity } from "lucide-react";
import {
Settings,
Globe,
Database,
FileCode,
Activity,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Page =
@@ -6,6 +13,8 @@ type Page =
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "settings";
interface SidebarProps {
@@ -18,6 +27,7 @@ const navItems = [
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
{ id: "config-management" as Page, label: "配置管理", icon: FileCode },
{ id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity },
{ id: "tools" as Page, label: "工具", icon: Wrench },
{ id: "settings" as Page, label: "设置", icon: Settings },
];
+132 -77
View File
@@ -1,7 +1,8 @@
import { Check, Edit2, Trash2 } from "lucide-react";
import { Check, Edit2, Trash2, Loader2 } from "lucide-react";
import { Provider } from "@/lib/api/switch";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "@/icons/providers";
import { useState } from "react";
// 从供应商名称和分类推断图标类型
function getProviderTypeFromName(name: string, category: string): string {
@@ -41,6 +42,7 @@ interface ProviderCardProps {
onSwitch: () => void;
onEdit: () => void;
onDelete: () => void;
switching?: boolean;
}
export function ProviderCard({
@@ -49,93 +51,146 @@ export function ProviderCard({
onSwitch,
onEdit,
onDelete,
switching = false,
}: ProviderCardProps) {
const [showConfirm, setShowConfirm] = useState(false);
const isProxyCast =
provider.category === "custom" && provider.name === "ProxyCast";
const handleClick = () => {
if (isCurrent || switching) return;
// 对于关键配置,显示确认对话框
if (provider.category === "official" || isProxyCast) {
setShowConfirm(true);
} else {
onSwitch();
}
};
const handleConfirmSwitch = () => {
setShowConfirm(false);
onSwitch();
};
return (
<div
onClick={() => !isCurrent && onSwitch()}
className={cn(
"group relative rounded-xl border p-3 transition-all cursor-pointer",
isCurrent
? "border-primary bg-gradient-to-r from-primary/10 to-transparent shadow-sm"
: "hover:border-primary/50 hover:shadow-md",
)}
>
{/* 选中标记 */}
{isCurrent && (
<div className="absolute -top-1.5 -right-1.5 rounded-full bg-primary p-1 shadow-sm">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex items-center gap-3">
{/* 图标 */}
<div
className={cn(
"shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
isCurrent ? "bg-primary/20" : "bg-muted",
)}
>
<ProviderIcon
providerType={getProviderTypeFromName(
provider.name,
provider.category || "",
)}
size={22}
showFallback={true}
/>
</div>
{/* 名称和分类 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{provider.name}</h3>
{provider.category && (
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{provider.category}
</span>
)}
<>
<div
onClick={handleClick}
className={cn(
"group relative rounded-xl border p-3 transition-all",
switching
? "cursor-wait opacity-75"
: isCurrent
? "cursor-default border-primary bg-gradient-to-r from-primary/10 to-transparent shadow-sm"
: "cursor-pointer hover:border-primary/50 hover:shadow-md",
)}
>
{/* 选中标记或切换状态 */}
{switching ? (
<div className="absolute -top-1.5 -right-1.5 rounded-full bg-blue-500 p-1 shadow-sm">
<Loader2 className="h-3 w-3 text-white animate-spin" />
</div>
{isProxyCast ? (
<p className="text-xs text-blue-600 dark:text-blue-400 truncate">
凭证池 → 标准 API
</p>
) : provider.notes ? (
<p className="text-xs text-muted-foreground truncate">
{provider.notes}
</p>
) : null}
</div>
) : isCurrent ? (
<div className="absolute -top-1.5 -right-1.5 rounded-full bg-primary p-1 shadow-sm">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
) : null}
{/* 操作按钮 */}
<div
className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
title="编辑"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => !isCurrent && onDelete()}
disabled={isCurrent}
<div className="flex items-center gap-3">
{/* 图标 */}
<div
className={cn(
"p-1.5 rounded text-destructive",
isCurrent
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10",
"shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
isCurrent ? "bg-primary/20" : "bg-muted",
)}
title={isCurrent ? "无法删除当前使用中的配置" : "删除"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ProviderIcon
providerType={getProviderTypeFromName(
provider.name,
provider.category || "",
)}
size={22}
showFallback={true}
/>
</div>
{/* 名称和分类 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{provider.name}</h3>
{provider.category && (
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{provider.category}
</span>
)}
</div>
{isProxyCast ? (
<p className="text-xs text-blue-600 dark:text-blue-400 truncate">
凭证池 → 标准 API
</p>
) : provider.notes ? (
<p className="text-xs text-muted-foreground truncate">
{provider.notes}
</p>
) : null}
</div>
{/* 操作按钮 */}
<div
className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
title="编辑"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => !isCurrent && onDelete()}
disabled={isCurrent}
className={cn(
"p-1.5 rounded text-destructive",
isCurrent
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10",
)}
title={isCurrent ? "无法删除当前使用中的配置" : "删除"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
{/* 切换确认对话框 */}
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm mx-4 shadow-xl">
<h3 className="text-lg font-medium mb-2">确认切换配置</h3>
<p className="text-sm text-muted-foreground mb-4">
您正在切换到 "{provider.name}" 配置,这将更改当前的 API
设置。确定要继续吗?
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowConfirm(false)}
className="px-3 py-1.5 text-sm rounded border hover:bg-muted"
>
取消
</button>
<button
onClick={handleConfirmSwitch}
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
确认切换
</button>
</div>
</div>
</div>
)}
</>
);
}
+19 -2
View File
@@ -210,6 +210,7 @@ export function ProviderList({ appType }: ProviderListProps) {
const [showSyncDialog, setShowSyncDialog] = useState(false);
const [syncResult, setSyncResult] = useState<SyncCheckResult | null>(null);
const [checkingSync, setCheckingSync] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
// 实际生效的配置
const [liveConfig, setLiveConfig] = useState<Record<string, unknown> | null>(
@@ -284,12 +285,15 @@ export function ProviderList({ appType }: ProviderListProps) {
// 切换到匹配的 provider
const handleSwitchToMatching = async (provider: Provider) => {
try {
setSwitchingId(provider.id);
await switchToProvider(provider.id);
// 切换后重新读取实际配置,更新 UI 状态
const config = await switchApi.readLiveSettings(appType);
setLiveConfig(config);
} catch (e) {
alert("切换失败: " + (e instanceof Error ? e.message : String(e)));
console.error("切换失败:", e);
} finally {
setSwitchingId(null);
}
};
@@ -523,7 +527,20 @@ export function ProviderList({ appType }: ProviderListProps) {
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProvider?.id}
onSwitch={() => switchToProvider(provider.id)}
switching={switchingId === provider.id}
onSwitch={async () => {
try {
setSwitchingId(provider.id);
await switchToProvider(provider.id);
// 切换后重新读取实际配置
const config = await switchApi.readLiveSettings(appType);
setLiveConfig(config);
} catch (e) {
console.error("切换失败:", e);
} finally {
setSwitchingId(null);
}
}}
onEdit={() => handleEdit(provider)}
onDelete={() => handleDeleteClick(provider.id)}
/>
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from "react";
import { Globe, Plus, Settings, Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
interface ToolsPageProps {
onNavigate: (
page:
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "settings",
) => void;
}
interface ToolCardProps {
title: string;
description: string;
icon: React.ReactNode;
status?: string;
disabled?: boolean;
onClick?: () => void;
}
function ToolCard({
title,
description,
icon,
status,
disabled = false,
onClick,
}: ToolCardProps) {
return (
<Card
className={`cursor-pointer transition-colors hover:bg-muted/50 ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-primary/10 rounded-lg">{icon}</div>
<div>
<CardTitle className="text-lg">{title}</CardTitle>
{status && (
<Badge
variant={status === "运行中" ? "default" : "secondary"}
className="mt-1"
>
{status}
</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-muted-foreground mb-4">
{description}
</CardDescription>
<Button
variant="outline"
size="sm"
disabled={disabled}
onClick={onClick}
className="w-full"
>
{disabled ? "敬请期待" : "打开工具"}
</Button>
</CardContent>
</Card>
);
}
export function ToolsPage({ onNavigate }: ToolsPageProps) {
const [interceptorEnabled] = useState(false); // TODO: 从状态管理中获取
const handleBrowserInterceptorClick = () => {
onNavigate("browser-interceptor");
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">工具箱</h1>
<p className="text-muted-foreground mt-1">
ProxyCast 提供的实用工具集合
</p>
</div>
<Badge variant="outline">1 个工具</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ToolCard
title="浏览器拦截器"
description="拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器"
icon={<Globe className="w-6 h-6 text-primary" />}
status={interceptorEnabled ? "运行中" : "已停止"}
onClick={handleBrowserInterceptorClick}
/>
{/* 未来可以添加更多工具 */}
<ToolCard
title="网络监控工具"
description="监控和分析网络请求,提供详细的流量分析"
icon={<Activity className="w-6 h-6 text-muted-foreground" />}
disabled
/>
<ToolCard
title="配置同步工具"
description="在多个设备间同步 ProxyCast 配置"
icon={<Settings className="w-6 h-6 text-muted-foreground" />}
disabled
/>
<ToolCard
title="更多工具"
description="更多实用工具正在开发中..."
icon={<Plus className="w-6 h-6 text-muted-foreground" />}
disabled
/>
</div>
<div className="mt-8 p-6 bg-muted/30 rounded-lg">
<h3 className="text-lg font-semibold mb-2">关于工具箱</h3>
<p className="text-sm text-muted-foreground">
工具箱是 ProxyCast
的扩展功能模块,提供各种实用工具来增强您的使用体验。
每个工具都经过精心设计,旨在解决特定的使用场景和需求。
</p>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import { useState, useEffect } from "react";
import { ArrowLeft, Globe, Settings, History, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusControlPanel } from "./StatusControlPanel";
import { InterceptedUrlsPanel } from "./InterceptedUrlsPanel";
import { InterceptorConfigPanel } from "./InterceptorConfigPanel";
import { UrlHistoryPanel } from "./UrlHistoryPanel";
import { SystemStatusPanel } from "./SystemStatusPanel";
import { InterceptorState } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
interface BrowserInterceptorToolProps {
onNavigate: (
page:
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "settings",
) => void;
}
export function BrowserInterceptorTool({
onNavigate,
}: BrowserInterceptorToolProps) {
const [interceptorState, setInterceptorState] =
useState<InterceptorState | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadInterceptorState();
// 每5秒刷新一次状态
const interval = setInterval(loadInterceptorState, 5000);
return () => clearInterval(interval);
}, []);
const loadInterceptorState = async () => {
try {
const state = await browserInterceptorApi.getBrowserInterceptorState();
setInterceptorState(state);
} catch (error) {
console.error("加载拦截器状态失败:", error);
} finally {
setLoading(false);
}
};
const handleStateChange = () => {
// 状态变化时重新加载状态
loadInterceptorState();
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<Globe className="w-16 h-16 mx-auto mb-4 animate-spin text-blue-500" />
<p className="text-gray-600">加载中...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
onClick={() => onNavigate("tools")}
className="p-2"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<div className="flex items-center space-x-3">
<Globe className="w-8 h-8 text-blue-500" />
<div>
<h1 className="text-3xl font-bold">浏览器拦截器</h1>
<p className="text-gray-600 text-sm mt-1">
拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器
</p>
</div>
</div>
</div>
{/* 状态控制面板 */}
<StatusControlPanel
state={interceptorState}
onStateChange={handleStateChange}
/>
</div>
{/* 主要功能标签页 */}
<Tabs defaultValue="intercepted" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger
value="intercepted"
className="flex items-center space-x-2"
>
<Globe className="w-4 h-4" />
<span>拦截的 URL</span>
</TabsTrigger>
<TabsTrigger value="config" className="flex items-center space-x-2">
<Settings className="w-4 h-4" />
<span>拦截配置</span>
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center space-x-2">
<History className="w-4 h-4" />
<span>历史记录</span>
</TabsTrigger>
<TabsTrigger value="status" className="flex items-center space-x-2">
<BarChart3 className="w-4 h-4" />
<span>系统状态</span>
</TabsTrigger>
<TabsTrigger value="help" className="flex items-center space-x-2">
<span>帮助</span>
</TabsTrigger>
</TabsList>
<TabsContent value="intercepted" className="mt-6">
<InterceptedUrlsPanel onStateChange={handleStateChange} />
</TabsContent>
<TabsContent value="config" className="mt-6">
<InterceptorConfigPanel onStateChange={handleStateChange} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<UrlHistoryPanel />
</TabsContent>
<TabsContent value="status" className="mt-6">
<SystemStatusPanel state={interceptorState} />
</TabsContent>
<TabsContent value="help" className="mt-6">
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-4">
🚀 浏览器拦截器使用指南
</h3>
<div className="space-y-4">
<div>
<h4 className="font-medium text-blue-800 mb-2">
什么是浏览器拦截器?
</h4>
<p className="text-blue-700 text-sm">
浏览器拦截器专门解决 Kiro、Cursor、VSCode 等桌面 AI 客户端的
OAuth 登录问题。
当这些应用尝试打开浏览器进行登录时,我们会拦截这些请求,让您可以手动在指纹浏览器中完成登录。
</p>
</div>
<div>
<h4 className="font-medium text-blue-800 mb-2">
🔧 工作原理
</h4>
<ul className="text-blue-700 text-sm space-y-1 ml-4">
<li>• 监听系统级的浏览器启动请求</li>
<li>• 识别来自目标应用的 URL 打开请求</li>
<li>• 阻止默认浏览器启动</li>
<li>• 捕获并存储 OAuth URL</li>
<li>• 提供一键复制和指纹浏览器启动功能</li>
</ul>
</div>
<div>
<h4 className="font-medium text-blue-800 mb-2">
💡 使用步骤
</h4>
<ol className="text-blue-700 text-sm space-y-1 ml-4">
<li>1. 启用浏览器拦截器</li>
<li>2. 在 Kiro 等应用中点击登录</li>
<li>3. 查看拦截到的 URL</li>
<li>4. 一键复制或在指纹浏览器中打开</li>
<li>5. 完成登录后可恢复正常浏览器行为</li>
</ol>
</div>
<div>
<h4 className="font-medium text-blue-800 mb-2">
⚠️ 重要提醒
</h4>
<ul className="text-blue-700 text-sm space-y-1 ml-4">
<li>• 使用完成后记得点击"恢复正常"以免影响其他软件</li>
<li>• 支持临时禁用功能,会在指定时间后自动恢复拦截</li>
<li>• 所有拦截记录都会保存在历史中,可随时查看</li>
<li>• 目前主要支持 Windows 平台,其他平台正在开发中</li>
</ul>
</div>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-800 mb-2">
🔐 隐私和安全
</h4>
<p className="text-yellow-700 text-sm">
拦截器仅捕获 OAuth 相关的 URL,不会记录任何敏感信息。
所有数据都保存在本地,不会上传到任何服务器。
</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,315 @@
import { useState, useEffect } from "react";
import {
Copy,
ExternalLink,
X,
Globe,
Clock,
RefreshCw,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { InterceptedUrl } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
interface InterceptedUrlsPanelProps {
onStateChange: () => void;
}
export function InterceptedUrlsPanel({
onStateChange,
}: InterceptedUrlsPanelProps) {
const [interceptedUrls, setInterceptedUrls] = useState<InterceptedUrl[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [operatingUrls, setOperatingUrls] = useState<Set<string>>(new Set());
useEffect(() => {
loadInterceptedUrls();
// 每10秒自动刷新
const interval = setInterval(loadInterceptedUrls, 10000);
return () => clearInterval(interval);
}, []);
const loadInterceptedUrls = async (showRefreshing = false) => {
if (showRefreshing) setRefreshing(true);
try {
const urls = await browserInterceptorApi.getInterceptedUrls();
setInterceptedUrls(urls);
} catch (error) {
console.error("加载拦截URL失败:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const handleRefresh = () => {
loadInterceptedUrls(true);
};
const setUrlOperating = (urlId: string, operating: boolean) => {
setOperatingUrls((prev) => {
const newSet = new Set(prev);
if (operating) {
newSet.add(urlId);
} else {
newSet.delete(urlId);
}
return newSet;
});
};
const handleCopyUrl = async (urlData: InterceptedUrl) => {
setUrlOperating(urlData.id, true);
try {
await browserInterceptorApi.copyInterceptedUrlToClipboard(urlData.id);
// 复制成功后重新加载数据以更新状态
await loadInterceptedUrls();
onStateChange();
} catch (error) {
console.error("复制URL失败:", error);
} finally {
setUrlOperating(urlData.id, false);
}
};
const handleOpenInBrowser = async (urlData: InterceptedUrl) => {
setUrlOperating(urlData.id, true);
try {
await browserInterceptorApi.openUrlInFingerprintBrowser(urlData.id);
await loadInterceptedUrls();
onStateChange();
} catch (error) {
console.error("在指纹浏览器中打开URL失败:", error);
} finally {
setUrlOperating(urlData.id, false);
}
};
const handleDismissUrl = async (urlData: InterceptedUrl) => {
setUrlOperating(urlData.id, true);
try {
await browserInterceptorApi.dismissInterceptedUrl(urlData.id);
await loadInterceptedUrls();
onStateChange();
} catch (error) {
console.error("忽略URL失败:", error);
} finally {
setUrlOperating(urlData.id, false);
}
};
const getSourceProcessIcon = (processName: string) => {
if (processName.toLowerCase().includes("kiro")) {
return "🤖";
} else if (processName.toLowerCase().includes("cursor")) {
return "💻";
} else if (processName.toLowerCase().includes("code")) {
return "📝";
}
return "🔗";
};
const truncateUrl = (url: string, maxLength = 60) => {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Globe className="w-5 h-5" />
<span>当前拦截的 URL</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-gray-600">加载中...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Globe className="w-5 h-5" />
<span>当前拦截的 URL</span>
<Badge variant="outline">{interceptedUrls.length}</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={`w-4 h-4 mr-1 ${refreshing ? "animate-spin" : ""}`}
/>
刷新
</Button>
</div>
</CardHeader>
<CardContent>
{interceptedUrls.length === 0 ? (
<div className="text-center py-12">
<Globe className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium text-gray-700 mb-2">
暂无拦截的 URL
</h3>
<p className="text-gray-500 max-w-md mx-auto">
当目标应用(如 Kiro、Cursor、VSCode)尝试打开浏览器时,URL
将显示在这里。 请确保拦截器已启用。
</p>
</div>
) : (
<ScrollArea className="h-96">
<div className="space-y-4">
{interceptedUrls.map((urlData) => (
<div
key={urlData.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
{/* URL 头部信息 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<span className="text-lg">
{getSourceProcessIcon(urlData.source_process)}
</span>
<Badge variant="outline" className="text-xs">
{urlData.source_process}
</Badge>
{urlData.copied && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="w-3 h-3 mr-1" />
已复制
</Badge>
)}
{urlData.opened_in_browser && (
<Badge variant="secondary" className="text-xs">
<ExternalLink className="w-3 h-3 mr-1" />
已打开
</Badge>
)}
</div>
<div className="flex items-center text-xs text-gray-500">
<Clock className="w-3 h-3 mr-1" />
{formatDistanceToNow(new Date(urlData.timestamp), {
addSuffix: true,
locale: zhCN,
})}
</div>
</div>
{/* URL 显示 */}
<div className="bg-gray-100 rounded-lg p-3 mb-3 font-mono text-sm break-all">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
{truncateUrl(urlData.url)}
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-lg"
>
<p className="break-all">{urlData.url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => handleCopyUrl(urlData)}
disabled={operatingUrls.has(urlData.id)}
className="flex items-center"
>
{operatingUrls.has(urlData.id) ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-1" />
) : (
<Copy className="w-4 h-4 mr-1" />
)}
{urlData.copied ? "重新复制" : "复制 URL"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleOpenInBrowser(urlData)}
disabled={operatingUrls.has(urlData.id)}
>
{operatingUrls.has(urlData.id) ? (
<div className="w-4 h-4 border-2 border-gray-500 border-t-transparent rounded-full animate-spin mr-1" />
) : (
<ExternalLink className="w-4 h-4 mr-1" />
)}
在指纹浏览器中打开
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismissUrl(urlData)}
disabled={operatingUrls.has(urlData.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{operatingUrls.has(urlData.id) ? (
<div className="w-4 h-4 border-2 border-red-500 border-t-transparent rounded-full animate-spin mr-1" />
) : (
<X className="w-4 h-4 mr-1" />
)}
忽略
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
)}
{/* 使用提示 */}
{interceptedUrls.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-2">
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-700">
<p className="font-medium mb-1">使用建议:</p>
<ul className="text-xs space-y-1">
<li>• 点击"复制 URL"将链接复制到剪贴板</li>
<li>• 点击"在指纹浏览器中打开"自动启动配置的浏览器</li>
<li>• 点击"忽略"将移除此URL(会保留在历史记录中)</li>
</ul>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,694 @@
import React, { useState, useEffect } from "react";
import {
Settings,
Plus,
Trash2,
Save,
RotateCcw,
AlertCircle,
CheckCircle,
FolderOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { BrowserInterceptorConfig } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
interface InterceptorConfigPanelProps {
onStateChange: () => void;
}
export function InterceptorConfigPanel({
onStateChange,
}: InterceptorConfigPanelProps) {
const [config, setConfig] = useState<BrowserInterceptorConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
message: string;
} | null>(null);
const [newTargetProcess, setNewTargetProcess] = useState("");
const [newUrlPattern, setNewUrlPattern] = useState("");
const [newExcludedProcess, setNewExcludedProcess] = useState("");
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const defaultConfig =
await browserInterceptorApi.getDefaultBrowserInterceptorConfig();
setConfig(defaultConfig);
} catch (error) {
console.error("加载配置失败:", error);
} finally {
setLoading(false);
}
};
const handleSaveConfig = async () => {
if (!config) return;
setSaving(true);
try {
// 先验证配置
await browserInterceptorApi.validateBrowserInterceptorConfig(config);
// 检查拦截器是否正在运行
const isRunning =
await browserInterceptorApi.isBrowserInterceptorRunning();
if (isRunning) {
// 拦截器已运行,更新配置
await browserInterceptorApi.updateBrowserInterceptorConfig(config);
setValidationResult({
valid: true,
message: "配置已更新",
});
} else {
// 拦截器未运行
if (config.enabled) {
// 用户启用了拦截器,使用配置启动
await browserInterceptorApi.startBrowserInterceptor(config);
setValidationResult({
valid: true,
message: "拦截器已启动",
});
} else {
// 用户未启用拦截器,仅保存配置到本地(配置已验证通过)
setValidationResult({
valid: true,
message:
"配置已验证通过。开启「启用拦截器」开关后点击保存即可启动。",
});
}
}
onStateChange();
} catch (error) {
console.error("保存配置失败:", error);
setValidationResult({
valid: false,
message: "保存配置失败: " + String(error),
});
} finally {
setSaving(false);
}
};
const handleValidateConfig = async () => {
if (!config) return;
setValidating(true);
try {
await browserInterceptorApi.validateBrowserInterceptorConfig(config);
setValidationResult({
valid: true,
message: "配置验证通过",
});
} catch (error) {
console.error("验证配置失败:", error);
setValidationResult({
valid: false,
message: "验证失败: " + String(error),
});
} finally {
setValidating(false);
}
};
const handleResetConfig = async () => {
setLoading(true);
try {
const defaultConfig =
await browserInterceptorApi.getDefaultBrowserInterceptorConfig();
setConfig(defaultConfig);
setValidationResult(null);
} catch (error) {
console.error("重置配置失败:", error);
} finally {
setLoading(false);
}
};
const updateConfig = (updates: Partial<BrowserInterceptorConfig>) => {
if (!config) return;
setConfig({ ...config, ...updates });
setValidationResult(null); // 清除验证结果
};
const addTargetProcess = () => {
if (!newTargetProcess.trim() || !config) return;
updateConfig({
target_processes: [...config.target_processes, newTargetProcess.trim()],
});
setNewTargetProcess("");
};
const removeTargetProcess = (index: number) => {
if (!config) return;
const newProcesses = [...config.target_processes];
newProcesses.splice(index, 1);
updateConfig({ target_processes: newProcesses });
};
const addUrlPattern = () => {
if (!newUrlPattern.trim() || !config) return;
updateConfig({
url_patterns: [...config.url_patterns, newUrlPattern.trim()],
});
setNewUrlPattern("");
};
const removeUrlPattern = (index: number) => {
if (!config) return;
const newPatterns = [...config.url_patterns];
newPatterns.splice(index, 1);
updateConfig({ url_patterns: newPatterns });
};
const addExcludedProcess = () => {
if (!newExcludedProcess.trim() || !config) return;
updateConfig({
excluded_processes: [
...config.excluded_processes,
newExcludedProcess.trim(),
],
});
setNewExcludedProcess("");
};
const removeExcludedProcess = (index: number) => {
if (!config) return;
const newProcesses = [...config.excluded_processes];
newProcesses.splice(index, 1);
updateConfig({ excluded_processes: newProcesses });
};
if (loading || !config) {
return (
<Card>
<CardContent className="p-8">
<div className="flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-2" />
<span>加载配置中...</span>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* 配置操作按钮 */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center">
<Settings className="w-5 h-5 mr-2" />
拦截器配置
</h2>
<div className="flex space-x-2">
<Button
variant="outline"
onClick={handleValidateConfig}
disabled={validating}
>
{validating ? (
<div className="w-4 h-4 border-2 border-gray-500 border-t-transparent rounded-full animate-spin mr-1" />
) : (
<CheckCircle className="w-4 h-4 mr-1" />
)}
验证配置
</Button>
<Button variant="outline" onClick={handleResetConfig}>
<RotateCcw className="w-4 h-4 mr-1" />
重置为默认
</Button>
<Button onClick={handleSaveConfig} disabled={saving}>
{saving ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-1" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
保存配置
</Button>
</div>
</div>
{/* 验证结果显示 */}
{validationResult && (
<div
className={`p-4 rounded-lg border ${
validationResult.valid
? "bg-green-50 border-green-200 text-green-700"
: "bg-red-50 border-red-200 text-red-700"
}`}
>
<div className="flex items-center space-x-2">
{validationResult.valid ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
<span className="font-medium">
{validationResult.valid ? "配置验证通过" : "配置验证失败"}
</span>
</div>
{validationResult.message && (
<p className="mt-1 text-sm">{validationResult.message}</p>
)}
</div>
)}
{/* 配置选项卡 */}
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="general">基本设置</TabsTrigger>
<TabsTrigger value="processes">进程配置</TabsTrigger>
<TabsTrigger value="patterns">URL 模式</TabsTrigger>
<TabsTrigger value="browser">浏览器设置</TabsTrigger>
</TabsList>
{/* 基本设置 */}
<TabsContent value="general" className="mt-6">
<Card>
<CardHeader>
<CardTitle>基本设置</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">启用拦截器</Label>
<p className="text-sm text-gray-600 mt-1">
开启后将拦截目标应用的浏览器启动请求
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => updateConfig({ enabled })}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">启用通知</Label>
<p className="text-sm text-gray-600 mt-1">
拦截URL时显示系统通知
</p>
</div>
<Switch
checked={config.notification_enabled}
onCheckedChange={(notification_enabled) =>
updateConfig({ notification_enabled })
}
/>
</div>
{config.notification_enabled && (
<div className="pl-0">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await browserInterceptorApi.showBrowserInterceptorStatusNotification(
"这是一个测试通知,如果您看到这条消息,说明通知功能正常工作!",
"info",
);
} catch (error) {
console.error("测试通知失败:", error);
setValidationResult({
valid: false,
message: "测试通知失败: " + String(error),
});
}
}}
>
<CheckCircle className="w-4 h-4 mr-1" />
测试通知
</Button>
</div>
)}
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
自动复制到剪贴板
</Label>
<p className="text-sm text-gray-600 mt-1">
拦截URL后自动复制到剪贴板
</p>
</div>
<Switch
checked={config.auto_copy_to_clipboard}
onCheckedChange={(auto_copy_to_clipboard) =>
updateConfig({ auto_copy_to_clipboard })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
退出时自动恢复
</Label>
<p className="text-sm text-gray-600 mt-1">
应用退出时自动恢复浏览器行为
</p>
</div>
<Switch
checked={config.restore_on_exit}
onCheckedChange={(restore_on_exit) =>
updateConfig({ restore_on_exit })
}
/>
</div>
<div className="space-y-2">
<Label className="text-base font-medium">
临时禁用超时(分钟)
</Label>
<p className="text-sm text-gray-600">
临时禁用后自动重新启用的时间,0 表示不自动恢复
</p>
<Input
type="number"
min="0"
max="1440"
value={
config.temporary_disable_timeout
? config.temporary_disable_timeout / 60
: 0
}
onChange={(e) => {
const minutes = parseInt(e.target.value) || 0;
updateConfig({
temporary_disable_timeout:
minutes > 0 ? minutes * 60 : null,
});
}}
className="max-w-xs"
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 进程配置 */}
<TabsContent value="processes" className="mt-6">
<div className="space-y-6">
{/* 目标进程 */}
<Card>
<CardHeader>
<CardTitle>目标进程</CardTitle>
<p className="text-sm text-gray-600">
配置需要拦截浏览器启动的应用程序名称
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex space-x-2">
<Input
placeholder="例如:kiro, cursor, code"
value={newTargetProcess}
onChange={(e) => setNewTargetProcess(e.target.value)}
onKeyPress={(e) =>
e.key === "Enter" && addTargetProcess()
}
/>
<Button
onClick={addTargetProcess}
disabled={!newTargetProcess.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{config.target_processes.map((process, index) => (
<Badge
key={index}
variant="outline"
className="flex items-center space-x-1"
>
<span>{process}</span>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 ml-1 hover:bg-transparent"
onClick={() => removeTargetProcess(index)}
>
<Trash2 className="w-3 h-3" />
</Button>
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
{/* 排除进程 */}
<Card>
<CardHeader>
<CardTitle>排除进程</CardTitle>
<p className="text-sm text-gray-600">
配置永不拦截的进程名称(如系统浏览器)
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex space-x-2">
<Input
placeholder="例如:explorer, chrome, firefox"
value={newExcludedProcess}
onChange={(e) => setNewExcludedProcess(e.target.value)}
onKeyPress={(e) =>
e.key === "Enter" && addExcludedProcess()
}
/>
<Button
onClick={addExcludedProcess}
disabled={!newExcludedProcess.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{config.excluded_processes.map((process, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center space-x-1"
>
<span>{process}</span>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 ml-1 hover:bg-transparent"
onClick={() => removeExcludedProcess(index)}
>
<Trash2 className="w-3 h-3" />
</Button>
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* URL 模式 */}
<TabsContent value="patterns" className="mt-6">
<Card>
<CardHeader>
<CardTitle>URL 匹配模式</CardTitle>
<p className="text-sm text-gray-600">
配置需要拦截的URL模式,支持通配符 *
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex space-x-2">
<Input
placeholder="例如:https://auth.*, https://*/oauth/*, localhost:*/auth"
value={newUrlPattern}
onChange={(e) => setNewUrlPattern(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && addUrlPattern()}
/>
<Button
onClick={addUrlPattern}
disabled={!newUrlPattern.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="space-y-2">
{config.url_patterns.map((pattern, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-gray-50 rounded border"
>
<code className="text-sm font-mono">{pattern}</code>
<Button
variant="ghost"
size="sm"
onClick={() => removeUrlPattern(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 浏览器设置 */}
<TabsContent value="browser" className="mt-6">
<Card>
<CardHeader>
<CardTitle>指纹浏览器设置</CardTitle>
<p className="text-sm text-gray-600">
配置指纹浏览器的路径和启动参数
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
启用指纹浏览器
</Label>
<p className="text-sm text-gray-600 mt-1">
启用后可以一键在指纹浏览器中打开URL
</p>
</div>
<Switch
checked={config.fingerprint_browser.enabled}
onCheckedChange={(enabled) =>
updateConfig({
fingerprint_browser: {
...config.fingerprint_browser,
enabled,
},
})
}
/>
</div>
{config.fingerprint_browser.enabled && (
<>
<div className="space-y-2">
<Label className="text-base font-medium">
浏览器可执行文件路径
</Label>
<div className="flex space-x-2">
<Input
placeholder="例如:C:\Program Files\Browser\browser.exe"
value={config.fingerprint_browser.executable_path}
onChange={(e) =>
updateConfig({
fingerprint_browser: {
...config.fingerprint_browser,
executable_path: e.target.value,
},
})
}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
<FolderOpen className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>浏览文件夹选择浏览器</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="space-y-2">
<Label className="text-base font-medium">
配置文件路径(可选)
</Label>
<Input
placeholder="例如:C:\Users\user\AppData\Local\Browser\Profile"
value={config.fingerprint_browser.profile_path}
onChange={(e) =>
updateConfig({
fingerprint_browser: {
...config.fingerprint_browser,
profile_path: e.target.value,
},
})
}
/>
</div>
<div className="space-y-2">
<Label className="text-base font-medium">
额外启动参数(可选)
</Label>
<Textarea
placeholder="每行一个参数,例如:&#10;--no-first-run&#10;--disable-background-timer-throttling"
value={config.fingerprint_browser.additional_args.join(
"\n",
)}
onChange={(e) =>
updateConfig({
fingerprint_browser: {
...config.fingerprint_browser,
additional_args: e.target.value
.split("\n")
.filter((arg) => arg.trim()),
},
})
}
rows={4}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
自动启动浏览器
</Label>
<p className="text-sm text-gray-600 mt-1">
拦截到URL时自动启动指纹浏览器
</p>
</div>
<Switch
checked={config.auto_launch_browser}
onCheckedChange={(auto_launch_browser) =>
updateConfig({ auto_launch_browser })
}
/>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,249 @@
import React, { useState } from "react";
import {
RotateCcw,
Pause,
Shield,
AlertCircle,
CheckCircle,
Clock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { InterceptorState } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
interface StatusControlPanelProps {
state: InterceptorState | null;
onStateChange: () => void;
}
export function StatusControlPanel({
state,
onStateChange,
}: StatusControlPanelProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleToggleInterceptor = async (enabled: boolean) => {
setIsLoading(true);
setError(null);
try {
if (enabled) {
// 使用默认配置启动拦截器,确保 enabled 为 true
const defaultConfig =
await browserInterceptorApi.getDefaultBrowserInterceptorConfig();
await browserInterceptorApi.startBrowserInterceptor({
...defaultConfig,
enabled: true, // 覆盖默认值,确保启动
});
} else {
await browserInterceptorApi.stopBrowserInterceptor();
}
onStateChange();
} catch (err) {
const errorMessage = String(err);
console.error("切换拦截器状态失败:", errorMessage);
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleRestoreNormal = async () => {
setIsLoading(true);
try {
await browserInterceptorApi.restoreNormalBrowserBehavior();
onStateChange();
} catch (error) {
console.error("恢复正常浏览器行为失败:", error);
} finally {
setIsLoading(false);
}
};
const handleTemporaryDisable = async (minutes: number) => {
setIsLoading(true);
try {
await browserInterceptorApi.temporaryDisableInterceptor(minutes * 60);
onStateChange();
} catch (error) {
console.error("临时禁用拦截器失败:", error);
} finally {
setIsLoading(false);
}
};
const getStatusColor = () => {
if (!state) return "bg-gray-400";
return state.enabled ? "bg-green-500" : "bg-gray-400";
};
const getStatusText = () => {
if (!state) return "已停止";
return state.enabled ? "拦截中" : "已停止";
};
const getStatusIcon = () => {
if (!state) return <Pause className="w-4 h-4" />;
return state.enabled ? (
<CheckCircle className="w-4 h-4" />
) : (
<Pause className="w-4 h-4" />
);
};
return (
<Card className="w-80">
<CardContent className="p-4">
{/* 状态显示 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full ${getStatusColor()}`} />
<div className="flex items-center space-x-2">
{getStatusIcon()}
<span className="font-medium">{getStatusText()}</span>
</div>
</div>
<Switch
checked={state?.enabled || false}
onCheckedChange={handleToggleInterceptor}
disabled={isLoading}
/>
</div>
{/* 统计信息 */}
<div className="space-y-2 text-sm text-gray-600 mb-4">
<div className="flex justify-between">
<span>已拦截:</span>
<Badge variant="outline">
{state?.intercepted_count || 0} 个 URL
</Badge>
</div>
<div className="flex justify-between">
<span>活跃钩子:</span>
<Badge variant="outline">
{state?.active_hooks?.length || 0} 个
</Badge>
</div>
{state?.last_activity && (
<div className="flex justify-between">
<span>最后活动:</span>
<span className="text-xs">
{formatDistanceToNow(new Date(state.last_activity), {
addSuffix: true,
locale: zhCN,
})}
</span>
</div>
)}
</div>
{/* 控制按钮 */}
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={handleRestoreNormal}
disabled={!state?.can_restore || isLoading}
className="flex-1"
>
<RotateCcw className="w-4 h-4 mr-1" />
恢复正常
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={isLoading}>
<Pause className="w-4 h-4 mr-1" />
临时禁用
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleTemporaryDisable(5)}>
<Clock className="w-4 h-4 mr-2" />
禁用 5 分钟
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTemporaryDisable(15)}>
<Clock className="w-4 h-4 mr-2" />
禁用 15 分钟
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTemporaryDisable(30)}>
<Clock className="w-4 h-4 mr-2" />
禁用 30 分钟
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTemporaryDisable(60)}>
<Clock className="w-4 h-4 mr-2" />
禁用 1 小时
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 状态提示 */}
{state?.enabled && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-2">
<Shield className="w-4 h-4 text-green-600" />
<span className="text-sm text-green-700 font-medium">
拦截器正在运行
</span>
</div>
<p className="text-xs text-green-600 mt-1">
桌面应用的浏览器启动请求将被拦截
</p>
</div>
)}
{!state?.enabled && state?.can_restore && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-yellow-600" />
<span className="text-sm text-yellow-700 font-medium">
可以恢复正常
</span>
</div>
<p className="text-xs text-yellow-600 mt-1">
点击"恢复正常"按钮完全恢复浏览器行为
</p>
</div>
)}
{isLoading && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-blue-700 font-medium">
操作中...
</span>
</div>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start space-x-2">
<AlertCircle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<span className="text-sm text-red-700 font-medium">
启动失败
</span>
<p className="text-xs text-red-600 mt-1 break-all">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,527 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Activity,
Shield,
Database,
AlertTriangle,
CheckCircle,
XCircle,
RefreshCw,
Monitor,
Settings,
Link,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { InterceptorState } from "@/lib/api/browserInterceptor";
interface SystemStatusPanelProps {
state: InterceptorState | null;
}
interface SystemDiagnostics {
registry_backup_exists: boolean;
registry_backup_size: number;
active_hooks_count: number;
memory_usage_mb: number;
cpu_usage_percent: number;
uptime_seconds: number;
last_error?: string;
last_error_time?: string;
total_intercepted_urls: number;
successful_operations: number;
failed_operations: number;
}
export function SystemStatusPanel({ state }: SystemStatusPanelProps) {
const [diagnostics, setDiagnostics] = useState<SystemDiagnostics | null>(
null,
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadDiagnostics = useCallback(async () => {
try {
// 模拟诊断信息加载(实际项目中这些信息应该来自后端)
const mockDiagnostics: SystemDiagnostics = {
registry_backup_exists: true,
registry_backup_size: 2048,
active_hooks_count: state?.active_hooks?.length || 0,
memory_usage_mb: Math.random() * 50 + 20, // 模拟20-70MB
cpu_usage_percent: Math.random() * 5 + 1, // 模拟1-6% CPU使用率
uptime_seconds: Date.now() / 1000 - (Date.now() / 1000 - 3600), // 模拟运行1小时
total_intercepted_urls: state?.intercepted_count || 0,
successful_operations: Math.floor(
(state?.intercepted_count || 0) * 0.95,
),
failed_operations: Math.floor((state?.intercepted_count || 0) * 0.05),
};
setDiagnostics(mockDiagnostics);
} catch (error) {
console.error("加载诊断信息失败:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [state?.active_hooks?.length, state?.intercepted_count]);
useEffect(() => {
loadDiagnostics();
// 每30秒自动刷新诊断信息
const interval = setInterval(loadDiagnostics, 30000);
return () => clearInterval(interval);
}, [loadDiagnostics]);
const handleRefresh = () => {
setRefreshing(true);
loadDiagnostics();
};
const getStatusColor = (status: "good" | "warning" | "error") => {
switch (status) {
case "good":
return "text-green-600 bg-green-50 border-green-200";
case "warning":
return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "error":
return "text-red-600 bg-red-50 border-red-200";
}
};
const getOverallStatus = () => {
if (!state) return { status: "error", text: "无法获取状态" };
if (!state.enabled) return { status: "warning", text: "拦截器已停用" };
if (!diagnostics?.registry_backup_exists)
return { status: "error", text: "注册表备份丢失" };
if (diagnostics?.failed_operations > 0)
return { status: "warning", text: "存在失败操作" };
return { status: "good", text: "系统运行正常" };
};
if (loading) {
return (
<Card>
<CardContent className="p-8">
<div className="flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-2" />
<span>加载系统状态中...</span>
</div>
</CardContent>
</Card>
);
}
const overallStatus = getOverallStatus();
return (
<div className="space-y-6">
{/* 系统概览 */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center">
<Activity className="w-5 h-5 mr-2" />
系统状态监控
</h2>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={`w-4 h-4 mr-1 ${refreshing ? "animate-spin" : ""}`}
/>
刷新
</Button>
</div>
{/* 整体状态卡片 */}
<Card
className={`border ${getStatusColor(overallStatus.status as "good" | "warning" | "error")}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{overallStatus.status === "good" && (
<CheckCircle className="w-6 h-6 text-green-600" />
)}
{overallStatus.status === "warning" && (
<AlertTriangle className="w-6 h-6 text-yellow-600" />
)}
{overallStatus.status === "error" && (
<XCircle className="w-6 h-6 text-red-600" />
)}
<div>
<h3 className="font-semibold">
系统状态:{overallStatus.text}
</h3>
<p className="text-sm opacity-75">
{state?.last_activity && (
<>
最后活动:
{formatDistanceToNow(new Date(state.last_activity), {
addSuffix: true,
locale: zhCN,
})}
</>
)}
</p>
</div>
</div>
<Badge
variant={
overallStatus.status === "good" ? "default" : "destructive"
}
>
{state?.enabled ? "运行中" : "已停止"}
</Badge>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 拦截器状态 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="w-4 h-4 mr-2" />
拦截器状态
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm">启用状态</span>
<Badge variant={state?.enabled ? "default" : "secondary"}>
{state?.enabled ? "已启用" : "已禁用"}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">活跃钩子</span>
<span className="text-sm font-medium">
{state?.active_hooks?.length || 0} 个
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">拦截计数</span>
<span className="text-sm font-medium">
{state?.intercepted_count || 0} 个
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">恢复能力</span>
<Badge variant={state?.can_restore ? "default" : "destructive"}>
{state?.can_restore ? "可恢复" : "无法恢复"}
</Badge>
</div>
{diagnostics?.last_error && (
<div className="p-3 bg-red-50 border border-red-200 rounded">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5" />
<div>
<div className="text-sm font-medium text-red-800">
最近错误
</div>
<div className="text-xs text-red-700 mt-1">
{diagnostics.last_error}
</div>
{diagnostics.last_error_time && (
<div className="text-xs text-red-600 mt-1">
{formatDistanceToNow(
new Date(diagnostics.last_error_time),
{ addSuffix: true, locale: zhCN },
)}
</div>
)}
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* 系统资源 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Monitor className="w-4 h-4 mr-2" />
系统资源
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span>内存使用</span>
<span>
{diagnostics?.memory_usage_mb?.toFixed(1) || "0"} MB
</span>
</div>
<Progress
value={((diagnostics?.memory_usage_mb || 0) / 100) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span>CPU 使用率</span>
<span>
{diagnostics?.cpu_usage_percent?.toFixed(1) || "0"}%
</span>
</div>
<Progress
value={diagnostics?.cpu_usage_percent || 0}
className="h-2"
/>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">运行时间</span>
<span className="text-sm font-medium">
{diagnostics?.uptime_seconds
? formatDistanceToNow(
new Date(Date.now() - diagnostics.uptime_seconds * 1000),
{ locale: zhCN },
)
: "未知"}
</span>
</div>
</CardContent>
</Card>
{/* 注册表状态 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Database className="w-4 h-4 mr-2" />
注册表状态
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm">备份状态</span>
<Badge
variant={
diagnostics?.registry_backup_exists
? "default"
: "destructive"
}
>
{diagnostics?.registry_backup_exists ? "已备份" : "无备份"}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">备份大小</span>
<span className="text-sm font-medium">
{diagnostics?.registry_backup_size
? `${(diagnostics.registry_backup_size / 1024).toFixed(1)} KB`
: "0 KB"}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex justify-between items-center cursor-help">
<span className="text-sm">修改位置</span>
<Badge variant="outline">HKEY_CLASSES_ROOT</Badge>
</div>
</TooltipTrigger>
<TooltipContent>
<p>浏览器注册表项修改位置</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
{/* 操作统计 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Activity className="w-4 h-4 mr-2" />
操作统计
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm">总拦截数</span>
<span className="text-sm font-medium">
{diagnostics?.total_intercepted_urls || 0}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">成功操作</span>
<span className="text-sm font-medium text-green-600">
{diagnostics?.successful_operations || 0}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">失败操作</span>
<span className="text-sm font-medium text-red-600">
{diagnostics?.failed_operations || 0}
</span>
</div>
{diagnostics && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>成功率</span>
<span>
{diagnostics.total_intercepted_urls > 0
? (
(diagnostics.successful_operations /
diagnostics.total_intercepted_urls) *
100
).toFixed(1)
: "100"}
%
</span>
</div>
<Progress
value={
diagnostics.total_intercepted_urls > 0
? (diagnostics.successful_operations /
diagnostics.total_intercepted_urls) *
100
: 100
}
className="h-2"
/>
</div>
)}
</CardContent>
</Card>
</div>
{/* 活跃钩子详情 */}
{state?.active_hooks && state.active_hooks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Link className="w-4 h-4 mr-2" />
活跃钩子详情
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
<div className="space-y-2">
{state.active_hooks.map((hook, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded border"
>
<div className="flex items-center space-x-2">
<Settings className="w-4 h-4 text-gray-600" />
<span className="font-mono text-sm">{hook}</span>
</div>
<Badge variant="outline">活跃</Badge>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
{/* 系统建议 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<AlertTriangle className="w-4 h-4 mr-2" />
系统建议
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{!diagnostics?.registry_backup_exists && (
<div className="p-3 bg-red-50 border border-red-200 rounded">
<div className="flex items-start space-x-2">
<XCircle className="w-4 h-4 text-red-600 mt-0.5" />
<div>
<div className="text-sm font-medium text-red-800">
注册表备份丢失
</div>
<div className="text-xs text-red-700 mt-1">
建议重新启动拦截器以创建备份,确保系统可以正常恢复。
</div>
</div>
</div>
</div>
)}
{diagnostics && diagnostics.failed_operations > 0 && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 mt-0.5" />
<div>
<div className="text-sm font-medium text-yellow-800">
存在失败操作
</div>
<div className="text-xs text-yellow-700 mt-1">
有 {diagnostics.failed_operations}{" "}
个操作失败,建议检查日志或重启拦截器。
</div>
</div>
</div>
</div>
)}
{!state?.enabled && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
<div className="flex items-start space-x-2">
<CheckCircle className="w-4 h-4 text-blue-600 mt-0.5" />
<div>
<div className="text-sm font-medium text-blue-800">
拦截器未启用
</div>
<div className="text-xs text-blue-700 mt-1">
拦截器当前未运行,不会拦截任何浏览器启动请求。
</div>
</div>
</div>
</div>
)}
{state?.enabled &&
diagnostics?.registry_backup_exists &&
(diagnostics?.failed_operations || 0) === 0 && (
<div className="p-3 bg-green-50 border border-green-200 rounded">
<div className="flex items-start space-x-2">
<CheckCircle className="w-4 h-4 text-green-600 mt-0.5" />
<div>
<div className="text-sm font-medium text-green-800">
系统运行正常
</div>
<div className="text-xs text-green-700 mt-1">
所有组件正常工作,拦截器已准备就绪。
</div>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback } from "react";
import {
History,
Search,
Download,
Trash2,
Copy,
ExternalLink,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { formatDistanceToNow, format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { InterceptedUrl } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
export function UrlHistoryPanel() {
const [history, setHistory] = useState<InterceptedUrl[]>([]);
const [filteredHistory, setFilteredHistory] = useState<InterceptedUrl[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedProcess, setSelectedProcess] = useState<string>("all");
const [uniqueProcesses, setUniqueProcesses] = useState<string[]>([]);
const [exporting, setExporting] = useState(false);
const loadHistory = async () => {
setLoading(true);
try {
const historyData = await browserInterceptorApi.getInterceptorHistory();
setHistory(historyData);
// 提取所有唯一的进程名
const processes = Array.from(
new Set(historyData.map((item) => item.source_process)),
).sort();
setUniqueProcesses(processes);
} catch (error) {
console.error("加载历史记录失败:", error);
} finally {
setLoading(false);
}
};
const filterHistory = useCallback(() => {
let filtered = [...history];
// 按搜索关键词过滤
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(item) =>
item.url.toLowerCase().includes(query) ||
item.source_process.toLowerCase().includes(query),
);
}
// 按进程过滤
if (selectedProcess !== "all") {
filtered = filtered.filter(
(item) => item.source_process === selectedProcess,
);
}
setFilteredHistory(filtered);
}, [history, searchQuery, selectedProcess]);
useEffect(() => {
loadHistory();
}, []);
useEffect(() => {
filterHistory();
}, [filterHistory]);
const handleExportHistory = async (format: "json" | "csv") => {
setExporting(true);
try {
// 创建下载链接
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `proxycast-interceptor-history-${timestamp}.${format}`;
let data: string;
let mimeType: string;
if (format === "json") {
data = JSON.stringify(filteredHistory, null, 2);
mimeType = "application/json";
} else {
// CSV 格式
const headers = [
"ID",
"URL",
"Source Process",
"Timestamp",
"Copied",
"Opened in Browser",
"Dismissed",
];
const csvRows = [
headers.join(","),
...filteredHistory.map((item) =>
[
`"${item.id}"`,
`"${item.url.replace(/"/g, '""')}"`,
`"${item.source_process}"`,
`"${item.timestamp}"`,
item.copied ? "true" : "false",
item.opened_in_browser ? "true" : "false",
item.dismissed ? "true" : "false",
].join(","),
),
];
data = csvRows.join("\n");
mimeType = "text/csv";
}
const blob = new Blob([data], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error("导出历史记录失败:", error);
} finally {
setExporting(false);
}
};
const handleClearHistory = async () => {
// TODO: 实现清空历史记录功能
console.log("清空历史记录(功能待实现)");
};
const getSourceProcessIcon = (processName: string) => {
if (processName.toLowerCase().includes("kiro")) {
return "🤖";
} else if (processName.toLowerCase().includes("cursor")) {
return "💻";
} else if (processName.toLowerCase().includes("code")) {
return "📝";
}
return "🔗";
};
const getStatusBadges = (item: InterceptedUrl) => {
const badges = [];
if (item.copied) {
badges.push(
<Badge key="copied" variant="secondary" className="text-xs">
已复制
</Badge>,
);
}
if (item.opened_in_browser) {
badges.push(
<Badge key="opened" variant="secondary" className="text-xs">
已打开
</Badge>,
);
}
if (item.dismissed) {
badges.push(
<Badge key="dismissed" variant="outline" className="text-xs">
已忽略
</Badge>,
);
}
return badges;
};
const copyUrlToClipboard = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch (error) {
console.error("复制URL失败:", error);
}
};
if (loading) {
return (
<Card>
<CardContent className="p-8">
<div className="flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-2" />
<span>加载历史记录中...</span>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* 搜索和过滤 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<History className="w-5 h-5" />
<span>历史记录</span>
<Badge variant="outline">
{filteredHistory.length} / {history.length}
</Badge>
</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={loadHistory}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={exporting}>
<Download className="w-4 h-4 mr-1" />
导出
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExportHistory("json")}>
导出为 JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExportHistory("csv")}>
导出为 CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Trash2 className="w-4 h-4 mr-1" />
清空
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>清空历史记录</DialogTitle>
<DialogDescription>
此操作将永久删除所有历史记录,此操作不可撤销。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">取消</Button>
<Button variant="destructive" onClick={handleClearHistory}>
确定清空
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex space-x-4">
<div className="flex-1">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<Input
placeholder="搜索 URL 或进程名..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={selectedProcess} onValueChange={setSelectedProcess}>
<SelectTrigger className="w-48">
<SelectValue placeholder="筛选进程" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">所有进程</SelectItem>
{uniqueProcesses.map((process) => (
<SelectItem key={process} value={process}>
<div className="flex items-center space-x-2">
<span>{getSourceProcessIcon(process)}</span>
<span>{process}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 历史记录列表 */}
<Card>
<CardContent className="p-0">
{filteredHistory.length === 0 ? (
<div className="text-center py-12">
<History className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium text-gray-700 mb-2">
{searchQuery || selectedProcess !== "all"
? "没有找到匹配的记录"
: "暂无历史记录"}
</h3>
<p className="text-gray-500 max-w-md mx-auto">
{searchQuery || selectedProcess !== "all"
? "请尝试调整搜索条件或筛选选项"
: "当拦截器开始工作时,拦截的URL将会显示在这里"}
</p>
</div>
) : (
<ScrollArea className="h-96">
<div className="p-4 space-y-3">
{filteredHistory.map((item, index) => (
<div
key={item.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
{/* 头部信息 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">
#{index + 1}
</span>
<span className="text-lg">
{getSourceProcessIcon(item.source_process)}
</span>
<Badge variant="outline" className="text-xs">
{item.source_process}
</Badge>
<div className="flex space-x-1">
{getStatusBadges(item)}
</div>
</div>
<div className="flex items-center space-x-3">
<div className="text-xs text-gray-500">
{format(
new Date(item.timestamp),
"yyyy-MM-dd HH:mm:ss",
)}
</div>
<div className="text-xs text-gray-400">
{formatDistanceToNow(new Date(item.timestamp), {
addSuffix: true,
locale: zhCN,
})}
</div>
</div>
</div>
{/* URL 显示 */}
<div className="bg-gray-100 rounded-lg p-3 mb-2 font-mono text-sm break-all">
{item.url}
</div>
{/* 操作按钮 */}
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => copyUrlToClipboard(item.url)}
>
<Copy className="w-3 h-3 mr-1" />
复制
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(item.url, "_blank")}
>
<ExternalLink className="w-3 h-3 mr-1" />
在浏览器中打开
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* 统计信息 */}
{history.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">统计概览</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{history.length}
</div>
<div className="text-sm text-gray-600">总拦截次数</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{history.filter((item) => item.copied).length}
</div>
<div className="text-sm text-gray-600">已复制</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{history.filter((item) => item.opened_in_browser).length}
</div>
<div className="text-sm text-gray-600">已打开</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">
{uniqueProcesses.length}
</div>
<div className="text-sm text-gray-600">涉及应用</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { cn } from "@/lib/utils";
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "secondary" | "destructive" | "outline";
}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant = "default", ...props }, ref) => {
const variants = {
default: "bg-blue-600 text-white",
secondary: "bg-gray-100 text-gray-900",
destructive: "bg-red-600 text-white",
outline: "text-gray-900 border border-gray-300",
};
return (
<div
ref={ref}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
variants[variant],
className,
)}
{...props}
/>
);
},
);
Badge.displayName = "Badge";
export { Badge };
+39
View File
@@ -0,0 +1,39 @@
import React from "react";
import { cn } from "@/lib/utils";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost" | "destructive" | "secondary";
size?: "default" | "sm" | "lg";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
const baseStyles =
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
const variants = {
default: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border border-gray-300 bg-transparent hover:bg-gray-50",
ghost: "hover:bg-gray-100",
destructive: "bg-red-600 text-white hover:bg-red-700",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
};
const sizes = {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
};
return (
<button
className={cn(baseStyles, variants[variant], sizes[size], className)}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button };
+81
View File
@@ -0,0 +1,81 @@
import React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-gray-200 bg-white shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-gray-600", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+133
View File
@@ -0,0 +1,133 @@
import React, { createContext, useContext, useState } from "react";
import { Modal } from "../Modal";
import { cn } from "@/lib/utils";
interface DialogContextType {
open: boolean;
setOpen: (open: boolean) => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
interface DialogProps {
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ children }) => {
const [open, setOpen] = useState(false);
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
);
};
interface DialogTriggerProps {
asChild?: boolean;
children: React.ReactNode;
}
const DialogTrigger: React.FC<DialogTriggerProps> = ({ asChild, children }) => {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogTrigger must be used within Dialog");
const { setOpen } = context;
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: () => setOpen(true),
});
}
return <button onClick={() => setOpen(true)}>{children}</button>;
};
interface DialogContentProps {
className?: string;
children: React.ReactNode;
}
const DialogContent: React.FC<DialogContentProps> = ({
className,
children,
}) => {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within Dialog");
const { open, setOpen } = context;
return (
<Modal isOpen={open} onClose={() => setOpen(false)} className={className}>
{children}
</Modal>
);
};
interface DialogHeaderProps {
className?: string;
children: React.ReactNode;
}
const DialogHeader: React.FC<DialogHeaderProps> = ({ className, children }) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
>
{children}
</div>
);
interface DialogFooterProps {
className?: string;
children: React.ReactNode;
}
const DialogFooter: React.FC<DialogFooterProps> = ({ className, children }) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
>
{children}
</div>
);
interface DialogTitleProps {
className?: string;
children: React.ReactNode;
}
const DialogTitle: React.FC<DialogTitleProps> = ({ className, children }) => (
<h2
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
>
{children}
</h2>
);
interface DialogDescriptionProps {
className?: string;
children: React.ReactNode;
}
const DialogDescription: React.FC<DialogDescriptionProps> = ({
className,
children,
}) => <p className={cn("text-sm text-gray-600", className)}>{children}</p>;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+151
View File
@@ -0,0 +1,151 @@
import React, {
createContext,
useContext,
useState,
useRef,
useEffect,
} from "react";
import { cn } from "@/lib/utils";
interface DropdownMenuContextType {
open: boolean;
setOpen: (open: boolean) => void;
}
const DropdownMenuContext = createContext<DropdownMenuContextType | undefined>(
undefined,
);
interface DropdownMenuProps {
children: React.ReactNode;
}
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
const [open, setOpen] = useState(false);
return (
<DropdownMenuContext.Provider value={{ open, setOpen }}>
<div className="relative">{children}</div>
</DropdownMenuContext.Provider>
);
};
interface DropdownMenuTriggerProps {
asChild?: boolean;
children: React.ReactNode;
}
const DropdownMenuTrigger: React.FC<DropdownMenuTriggerProps> = ({
asChild,
children,
}) => {
const context = useContext(DropdownMenuContext);
if (!context)
throw new Error("DropdownMenuTrigger must be used within DropdownMenu");
const { setOpen } = context;
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: () => setOpen(true),
});
}
return <button onClick={() => setOpen(true)}>{children}</button>;
};
interface DropdownMenuContentProps {
className?: string;
align?: "start" | "center" | "end";
children: React.ReactNode;
}
const DropdownMenuContent: React.FC<DropdownMenuContentProps> = ({
className,
align = "center",
children,
}) => {
const context = useContext(DropdownMenuContext);
if (!context)
throw new Error("DropdownMenuContent must be used within DropdownMenu");
const { open, setOpen } = context;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
};
if (open) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [open, setOpen]);
if (!open) return null;
const alignmentClasses = {
start: "left-0",
center: "left-1/2 transform -translate-x-1/2",
end: "right-0",
};
return (
<div
ref={ref}
className={cn(
"absolute top-full z-50 mt-1 min-w-32 rounded-md border bg-white shadow-md",
alignmentClasses[align],
className,
)}
>
{children}
</div>
);
};
interface DropdownMenuItemProps {
className?: string;
children: React.ReactNode;
onClick?: () => void;
}
const DropdownMenuItem: React.FC<DropdownMenuItemProps> = ({
className,
children,
onClick,
}) => {
const context = useContext(DropdownMenuContext);
if (!context)
throw new Error("DropdownMenuItem must be used within DropdownMenu");
const { setOpen } = context;
const handleClick = () => {
onClick?.();
setOpen(false);
};
return (
<div
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-gray-100",
className,
)}
onClick={handleClick}
>
{children}
</div>
);
};
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
};
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import { cn } from "@/lib/utils";
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import { cn } from "@/lib/utils";
type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement>;
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
),
);
Label.displayName = "Label";
export { Label };
+34
View File
@@ -0,0 +1,34 @@
import React from "react";
import { cn } from "@/lib/utils";
interface ProgressProps {
value?: number;
className?: string;
indicatorClassName?: string;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, indicatorClassName, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-gray-200",
className,
)}
{...props}
>
<div
className={cn(
"h-full w-full flex-1 bg-blue-500 transition-all duration-200 ease-in-out",
indicatorClassName,
)}
style={{
transform: `translateX(-${100 - (value || 0)}%)`,
}}
/>
</div>
),
);
Progress.displayName = "Progress";
export { Progress };
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import { cn } from "@/lib/utils";
type ScrollAreaProps = React.HTMLAttributes<HTMLDivElement>;
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-auto", className)}
{...props}
>
{children}
</div>
),
);
ScrollArea.displayName = "ScrollArea";
export { ScrollArea };
+151
View File
@@ -0,0 +1,151 @@
import React, { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
interface SelectContextType {
value: string;
onValueChange: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
}
const SelectContext = createContext<SelectContextType | undefined>(undefined);
interface SelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
const Select: React.FC<SelectProps> = ({
value,
defaultValue,
onValueChange,
children,
}) => {
const [internalValue, setInternalValue] = useState(defaultValue || "");
const [open, setOpen] = useState(false);
const currentValue = value !== undefined ? value : internalValue;
const handleValueChange = onValueChange || setInternalValue;
return (
<SelectContext.Provider
value={{
value: currentValue,
onValueChange: handleValueChange,
open,
setOpen,
}}
>
<div className="relative">{children}</div>
</SelectContext.Provider>
);
};
interface SelectTriggerProps {
className?: string;
children: React.ReactNode;
}
const SelectTrigger: React.FC<SelectTriggerProps> = ({
className,
children,
}) => {
const context = useContext(SelectContext);
if (!context) throw new Error("SelectTrigger must be used within Select");
const { open, setOpen } = context;
return (
<button
type="button"
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onClick={() => setOpen(!open)}
>
{children}
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
);
};
interface SelectValueProps {
placeholder?: string;
className?: string;
}
const SelectValue: React.FC<SelectValueProps> = ({ placeholder }) => {
const context = useContext(SelectContext);
if (!context) throw new Error("SelectValue must be used within Select");
const { value } = context;
return <span>{value || placeholder}</span>;
};
interface SelectContentProps {
className?: string;
children: React.ReactNode;
}
const SelectContent: React.FC<SelectContentProps> = ({
className,
children,
}) => {
const context = useContext(SelectContext);
if (!context) throw new Error("SelectContent must be used within Select");
const { open } = context;
if (!open) return null;
return (
<div
className={cn(
"absolute top-full z-50 w-full rounded-md border bg-white shadow-md",
className,
)}
>
{children}
</div>
);
};
interface SelectItemProps {
value: string;
className?: string;
children: React.ReactNode;
}
const SelectItem: React.FC<SelectItemProps> = ({
value,
className,
children,
}) => {
const context = useContext(SelectContext);
if (!context) throw new Error("SelectItem must be used within Select");
const { onValueChange, setOpen } = context;
const handleSelect = () => {
onValueChange(value);
setOpen(false);
};
return (
<div
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-gray-100",
className,
)}
onClick={handleSelect}
>
{children}
</div>
);
};
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, checked = false, onCheckedChange, ...props }, ref) => {
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-blue-600" : "bg-gray-200",
className,
)}
onClick={() => onCheckedChange?.(!checked)}
ref={ref}
{...props}
>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform",
checked ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
);
},
);
Switch.displayName = "Switch";
export { Switch };
+126
View File
@@ -0,0 +1,126 @@
import React, { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
interface TabsContextType {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextType | undefined>(undefined);
interface TabsProps {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
className?: string;
}
const Tabs: React.FC<TabsProps> = ({
defaultValue,
value,
onValueChange,
children,
className,
}) => {
const [internalValue, setInternalValue] = useState(defaultValue || "");
const currentValue = value !== undefined ? value : internalValue;
const handleValueChange = onValueChange || setInternalValue;
return (
<TabsContext.Provider
value={{ value: currentValue, onValueChange: handleValueChange }}
>
<div className={cn("w-full", className)}>{children}</div>
</TabsContext.Provider>
);
};
interface TabsListProps {
className?: string;
children: React.ReactNode;
}
const TabsList: React.FC<TabsListProps> = ({ className, children }) => (
<div
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-gray-100 p-1 text-gray-500",
className,
)}
>
{children}
</div>
);
interface TabsTriggerProps {
value: string;
className?: string;
children: React.ReactNode;
}
const TabsTrigger: React.FC<TabsTriggerProps> = ({
value,
className,
children,
}) => {
const context = useContext(TabsContext);
if (!context) {
throw new Error("TabsTrigger must be used within Tabs");
}
const { value: currentValue, onValueChange } = context;
const isActive = currentValue === value;
return (
<button
type="button"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive
? "bg-white text-gray-950 shadow-sm"
: "text-gray-600 hover:text-gray-900",
className,
)}
onClick={() => onValueChange(value)}
>
{children}
</button>
);
};
interface TabsContentProps {
value: string;
className?: string;
children: React.ReactNode;
}
const TabsContent: React.FC<TabsContentProps> = ({
value,
className,
children,
}) => {
const context = useContext(TabsContext);
if (!context) {
throw new Error("TabsContent must be used within Tabs");
}
const { value: currentValue } = context;
if (currentValue !== value) {
return null;
}
return (
<div
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
>
{children}
</div>
);
};
export { Tabs, TabsList, TabsTrigger, TabsContent };
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import { cn } from "@/lib/utils";
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+114
View File
@@ -0,0 +1,114 @@
import React, { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
interface TooltipContextType {
open: boolean;
setOpen: (open: boolean) => void;
}
const TooltipContext = createContext<TooltipContextType | undefined>(undefined);
interface TooltipProviderProps {
children: React.ReactNode;
}
const TooltipProvider: React.FC<TooltipProviderProps> = ({ children }) => {
return <>{children}</>;
};
interface TooltipProps {
children: React.ReactNode;
}
const Tooltip: React.FC<TooltipProps> = ({ children }) => {
const [open, setOpen] = useState(false);
return (
<TooltipContext.Provider value={{ open, setOpen }}>
<div className="relative">{children}</div>
</TooltipContext.Provider>
);
};
interface TooltipTriggerProps {
asChild?: boolean;
children: React.ReactNode;
}
const TooltipTrigger: React.FC<TooltipTriggerProps> = ({
asChild,
children,
}) => {
const context = useContext(TooltipContext);
if (!context) throw new Error("TooltipTrigger must be used within Tooltip");
const { setOpen } = context;
const handleMouseEnter = () => setOpen(true);
const handleMouseLeave = () => setOpen(false);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
});
}
return (
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{children}
</div>
);
};
interface TooltipContentProps {
className?: string;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
children: React.ReactNode;
}
const TooltipContent: React.FC<TooltipContentProps> = ({
className,
side = "top",
align = "center",
children,
}) => {
const context = useContext(TooltipContext);
if (!context) throw new Error("TooltipContent must be used within Tooltip");
const { open } = context;
if (!open) return null;
const sideClasses = {
top: "bottom-full mb-2",
right: "left-full ml-2",
bottom: "top-full mt-2",
left: "right-full mr-2",
};
const alignClasses = {
start: side === "top" || side === "bottom" ? "left-0" : "top-0",
center:
side === "top" || side === "bottom"
? "left-1/2 transform -translate-x-1/2"
: "top-1/2 transform -translate-y-1/2",
end: side === "top" || side === "bottom" ? "right-0" : "bottom-0",
};
return (
<div
className={cn(
"absolute z-50 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white shadow-md",
sideClasses[side],
alignClasses[align],
className,
)}
>
{children}
</div>
);
};
export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
+31 -3
View File
@@ -62,9 +62,37 @@ export function useSwitch(appType: AppType) {
};
const switchToProvider = async (id: string) => {
await switchApi.switchProvider(appType, id);
await fetchProviders();
toast.success("切换成功");
try {
// 显示加载状态
const loadingToast = toast.loading("正在切换配置...");
await switchApi.switchProvider(appType, id);
await fetchProviders();
// 关闭加载提示,显示成功消息
toast.dismiss(loadingToast);
toast.success("配置切换成功");
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error("配置切换失败:", errorMessage);
// 提供详细的错误信息
if (errorMessage.includes("Provider not found")) {
toast.error("配置不存在,请刷新后重试");
} else if (errorMessage.includes("Failed to sync")) {
toast.error(
`配置文件同步失败: ${errorMessage.replace("Failed to sync: ", "")}`,
);
} else if (errorMessage.includes("Permission denied")) {
toast.error("权限不足,请检查配置文件权限");
} else {
toast.error(`切换失败: ${errorMessage}`);
}
// 重新加载当前状态
await fetchProviders();
throw e;
}
};
const checkConfigSync = async (): Promise<SyncCheckResult> => {
+211
View File
@@ -0,0 +1,211 @@
import { invoke } from "@tauri-apps/api/core";
// 类型定义
export interface InterceptorState {
enabled: boolean;
active_hooks: string[];
intercepted_count: number;
last_activity?: string;
can_restore: boolean;
}
export interface InterceptedUrl {
id: string;
url: string;
source_process: string;
timestamp: string;
copied: boolean;
opened_in_browser: boolean;
dismissed: boolean;
}
export interface FingerprintBrowserConfig {
enabled: boolean;
executable_path: string;
profile_path: string;
additional_args: string[];
}
export interface RecoveryConfig {
backup_system_state: boolean;
emergency_recovery_hotkey: string;
auto_recovery_on_crash: boolean;
recovery_timeout: number;
}
export interface BrowserInterceptorConfig {
enabled: boolean;
target_processes: string[];
url_patterns: string[];
excluded_processes: string[];
notification_enabled: boolean;
auto_copy_to_clipboard: boolean;
restore_on_exit: boolean;
temporary_disable_timeout?: number | null;
auto_launch_browser: boolean;
fingerprint_browser: FingerprintBrowserConfig;
recovery: RecoveryConfig;
}
export interface InterceptorStatistics {
total_intercepted: number;
current_intercepted: number;
copied_count: number;
opened_count: number;
dismissed_count: number;
}
// API 函数
export const browserInterceptorApi = {
// 获取拦截器状态
async getState(): Promise<InterceptorState | null> {
return await invoke("get_browser_interceptor_state");
},
// 启动拦截器
async start(config: BrowserInterceptorConfig): Promise<string> {
return await invoke("start_browser_interceptor", { config });
},
// 停止拦截器
async stop(): Promise<string> {
return await invoke("stop_browser_interceptor");
},
// 恢复正常浏览器行为
async restoreNormalBehavior(): Promise<string> {
return await invoke("restore_normal_browser_behavior");
},
// 临时禁用拦截器
async temporaryDisable(durationSeconds: number): Promise<string> {
return await invoke("temporary_disable_interceptor", { durationSeconds });
},
// 获取拦截的 URL 列表
async getInterceptedUrls(): Promise<InterceptedUrl[]> {
return await invoke("get_intercepted_urls");
},
// 获取历史记录
async getHistory(limit?: number): Promise<InterceptedUrl[]> {
return await invoke("get_interceptor_history", { limit });
},
// 复制 URL 到剪贴板
async copyUrlToClipboard(urlId: string): Promise<string> {
return await invoke("copy_intercepted_url_to_clipboard", { urlId });
},
// 在指纹浏览器中打开 URL
async openInFingerprintBrowser(urlId: string): Promise<string> {
return await invoke("open_url_in_fingerprint_browser", { urlId });
},
// 忽略 URL
async dismissUrl(urlId: string): Promise<string> {
return await invoke("dismiss_intercepted_url", { urlId });
},
// 更新配置
async updateConfig(config: BrowserInterceptorConfig): Promise<string> {
return await invoke("update_browser_interceptor_config", { config });
},
// 获取默认配置
async getDefaultConfig(): Promise<BrowserInterceptorConfig> {
return await invoke("get_default_browser_interceptor_config");
},
// 验证配置
async validateConfig(config: BrowserInterceptorConfig): Promise<string> {
return await invoke("validate_browser_interceptor_config", { config });
},
// 检查是否正在运行
async isRunning(): Promise<boolean> {
return await invoke("is_browser_interceptor_running");
},
// 获取统计信息
async getStatistics(): Promise<InterceptorStatistics> {
return await invoke("get_browser_interceptor_statistics");
},
// 通知相关函数
async showNotification(
title: string,
body: string,
icon?: string,
): Promise<string> {
return await invoke("show_notification", { title, body, icon });
},
async showUrlInterceptNotification(
url: string,
sourceProcess: string,
): Promise<string> {
return await invoke("show_url_intercept_notification", {
url,
sourceProcess,
});
},
async showStatusNotification(
message: string,
notificationType: string,
): Promise<string> {
return await invoke("show_status_notification", {
message,
notificationType,
});
},
};
// 导出便捷函数
export const getBrowserInterceptorState = () =>
browserInterceptorApi.getState();
export const startBrowserInterceptor = (config: BrowserInterceptorConfig) =>
browserInterceptorApi.start(config);
export const stopBrowserInterceptor = () => browserInterceptorApi.stop();
export const restoreNormalBrowserBehavior = () =>
browserInterceptorApi.restoreNormalBehavior();
export const temporaryDisableInterceptor = (durationSeconds: number) =>
browserInterceptorApi.temporaryDisable(durationSeconds);
export const getInterceptedUrls = () =>
browserInterceptorApi.getInterceptedUrls();
export const getInterceptorHistory = (limit?: number) =>
browserInterceptorApi.getHistory(limit);
export const copyInterceptedUrlToClipboard = (urlId: string) =>
browserInterceptorApi.copyUrlToClipboard(urlId);
export const openUrlInFingerprintBrowser = (urlId: string) =>
browserInterceptorApi.openInFingerprintBrowser(urlId);
export const dismissInterceptedUrl = (urlId: string) =>
browserInterceptorApi.dismissUrl(urlId);
export const updateBrowserInterceptorConfig = (
config: BrowserInterceptorConfig,
) => browserInterceptorApi.updateConfig(config);
export const getDefaultBrowserInterceptorConfig = () =>
browserInterceptorApi.getDefaultConfig();
export const validateBrowserInterceptorConfig = (
config: BrowserInterceptorConfig,
) => browserInterceptorApi.validateConfig(config);
export const isBrowserInterceptorRunning = () =>
browserInterceptorApi.isRunning();
export const getBrowserInterceptorStatistics = () =>
browserInterceptorApi.getStatistics();
// 通知函数导出
export const showBrowserInterceptorNotification = (
title: string,
body: string,
icon?: string,
) => browserInterceptorApi.showNotification(title, body, icon);
export const showUrlInterceptNotification = (
url: string,
sourceProcess: string,
) => browserInterceptorApi.showUrlInterceptNotification(url, sourceProcess);
export const showBrowserInterceptorStatusNotification = (
message: string,
type: string,
) => browserInterceptorApi.showStatusNotification(message, type);
+201
View File
@@ -0,0 +1,201 @@
// 系统通知管理器
import { invoke } from "@tauri-apps/api/core";
export interface NotificationConfig {
title: string;
body: string;
icon?: string;
sound?: boolean;
actions?: NotificationAction[];
}
export interface NotificationAction {
id: string;
title: string;
}
export interface InterceptNotificationData {
urlId: string;
url: string;
sourceProcess: string;
timestamp: string;
}
class NotificationManager {
private enabled = true;
constructor() {
// 检查通知权限
this.checkPermission();
}
/**
* 检查并请求通知权限
*/
async checkPermission(): Promise<boolean> {
// 在Tauri中,通知权限通常在应用启动时处理
return true;
}
/**
* 设置通知是否启用
*/
setEnabled(enabled: boolean) {
this.enabled = enabled;
}
/**
* 显示URL拦截通知
*/
async showInterceptNotification(
data: InterceptNotificationData,
): Promise<void> {
if (!this.enabled) return;
try {
const config: NotificationConfig = {
title: "🔐 拦截到新的URL",
body: `来自 ${data.sourceProcess}: ${this.truncateUrl(data.url)}`,
icon: "icon",
sound: true,
actions: [
{ id: "copy", title: "复制URL" },
{ id: "open", title: "打开浏览器" },
{ id: "dismiss", title: "忽略" },
],
};
await this.showNotification(config);
} catch (error) {
console.error("显示拦截通知失败:", error);
}
}
/**
* 显示系统状态通知
*/
async showStatusNotification(
title: string,
message: string,
type: "info" | "success" | "warning" | "error" = "info",
): Promise<void> {
if (!this.enabled) return;
const icons = {
info: "ℹ️",
success: "✅",
warning: "⚠️",
error: "❌",
};
try {
const config: NotificationConfig = {
title: `${icons[type]} ${title}`,
body: message,
sound: type === "error" || type === "warning",
};
await this.showNotification(config);
} catch (error) {
console.error("显示状态通知失败:", error);
}
}
/**
* 显示通知
*/
private async showNotification(config: NotificationConfig): Promise<void> {
try {
// 尝试使用Tauri的通知API
await invoke("show_notification", {
title: config.title,
body: config.body,
icon: config.icon,
});
} catch (error) {
// 如果Tauri通知不可用,降级使用Web通知
console.warn("Tauri通知不可用,使用Web通知:", error);
await this.showWebNotification(config);
}
}
/**
* 显示Web通知(降级方案)
*/
private async showWebNotification(config: NotificationConfig): Promise<void> {
try {
if (!("Notification" in window)) {
console.warn("浏览器不支持通知");
return;
}
// 检查权限
if (Notification.permission === "default") {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.warn("用户拒绝了通知权限");
return;
}
}
if (Notification.permission === "granted") {
const notification = new Notification(config.title, {
body: config.body,
icon: config.icon || "/icon.png",
requireInteraction: true,
});
// 设置点击事件
notification.onclick = () => {
window.focus();
notification.close();
};
// 自动关闭
setTimeout(() => {
notification.close();
}, 5000);
}
} catch (error) {
console.error("显示Web通知失败:", error);
}
}
/**
* 截断长URL用于显示
*/
private truncateUrl(url: string, maxLength = 60): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
}
/**
* 显示测试通知
*/
async showTestNotification(): Promise<void> {
await this.showStatusNotification(
"通知测试",
"如果您看到这条消息,说明通知功能正常工作!",
"info",
);
}
}
// 创建全局通知管理器实例
export const notificationManager = new NotificationManager();
// 导出便捷方法
export const showInterceptNotification = (data: InterceptNotificationData) =>
notificationManager.showInterceptNotification(data);
export const showStatusNotification = (
title: string,
message: string,
type?: "info" | "success" | "warning" | "error",
) => notificationManager.showStatusNotification(title, message, type);
export const showTestNotification = () =>
notificationManager.showTestNotification();
export const setNotificationsEnabled = (enabled: boolean) =>
notificationManager.setEnabled(enabled);
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -12,8 +12,8 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {