mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
@@ -4,6 +4,8 @@ node_modules/
|
||||
# Build
|
||||
dist/
|
||||
src-tauri/target/
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
Generated
+81
-2
@@ -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
@@ -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",
|
||||
|
||||
Generated
+472
-15
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(¬ification).await?;
|
||||
|
||||
// 发送到前端(通过事件系统)
|
||||
self.send_frontend_notification(¬ification).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,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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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="每行一个参数,例如: --no-first-run --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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -12,8 +12,8 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user