Compare commits
447 Commits
v4.1.6
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe02ff0d84 | ||
|
|
33fde44cc3 | ||
|
|
fc9b1ead9e | ||
|
|
c5f629ac4a | ||
|
|
898d2c7f29 | ||
|
|
4aa0f517bf | ||
|
|
682f43bf2f | ||
|
|
bc2e7d616a | ||
|
|
ef2bbe5c22 | ||
|
|
4de4a74eca | ||
|
|
0ba1067123 | ||
|
|
b7c7ca4376 | ||
|
|
c91163abac | ||
|
|
f40f3225df | ||
|
|
3a99eb8338 | ||
|
|
a902ef70d9 | ||
|
|
c9498d5079 | ||
|
|
b9d8b303a1 | ||
|
|
c94405e5bb | ||
|
|
9697dcb703 | ||
|
|
55ee72225e | ||
|
|
e47eaf273e | ||
|
|
aa16c87afc | ||
|
|
bd439b7179 | ||
|
|
678c08b507 | ||
|
|
216a6011bd | ||
|
|
e12caa16a6 | ||
|
|
55885449a3 | ||
|
|
06c020d9ca | ||
|
|
da84623898 | ||
|
|
5221d427ed | ||
|
|
6c84e0c35a | ||
|
|
167ce3fae0 | ||
|
|
b8bcfa23be | ||
|
|
3bff868df1 | ||
|
|
74012ab252 | ||
|
|
574ba94e0e | ||
|
|
6fdeaacb5c | ||
|
|
a1ab0834b7 | ||
|
|
ade07b8578 | ||
|
|
00bd632ad9 | ||
|
|
e83fcfdc4c | ||
|
|
95dd2ea551 | ||
|
|
a36da9d565 | ||
|
|
111a1961bf | ||
|
|
fd4a214f9f | ||
|
|
f9122492db | ||
|
|
ab1d64e0c9 | ||
|
|
93bafbd9f7 | ||
|
|
419a53d6ec | ||
|
|
2b22975933 | ||
|
|
e049bfd606 | ||
|
|
a377669b73 | ||
|
|
5da4454af9 | ||
|
|
5588721566 | ||
|
|
2e77d9468a | ||
|
|
9f45c3f5eb | ||
|
|
1921d36e17 | ||
|
|
72569a520e | ||
|
|
00b63eed54 | ||
|
|
9af1a0ad56 | ||
|
|
7aeff80bf9 | ||
|
|
0d387f05de | ||
|
|
f40b039426 | ||
|
|
0cbba05263 | ||
|
|
1904aa918e | ||
|
|
a05cde93bd | ||
|
|
8f7ece7691 | ||
|
|
86daa8ef06 | ||
|
|
24eceef6cb | ||
|
|
4ba567ca09 | ||
|
|
4446d9439d | ||
|
|
6225df296c | ||
|
|
9f3736ef40 | ||
|
|
1be03734a4 | ||
|
|
7435ab49ab | ||
|
|
9c5426159d | ||
|
|
f3bb548626 | ||
|
|
34cdaa508c | ||
|
|
a734cedac1 | ||
|
|
5da98ddc8a | ||
|
|
e79d18da03 | ||
|
|
69a598f196 | ||
|
|
ac84606f20 | ||
|
|
b086507569 | ||
|
|
360f4917b1 | ||
|
|
89d0f22dac | ||
|
|
f4d63d01bd | ||
|
|
59a0b1bf16 | ||
|
|
48ca54a856 | ||
|
|
bf3dfbba0f | ||
|
|
bd1bd8a8aa | ||
|
|
7e1ca95bef | ||
|
|
b7cb2cd42d | ||
|
|
6359123323 | ||
|
|
f2f78bb4e2 | ||
|
|
716b21b0dd | ||
|
|
cde3590986 | ||
|
|
f89ad6ec15 | ||
|
|
4efa169313 | ||
|
|
933912f15d | ||
|
|
4e216ce036 | ||
|
|
567fcd3683 | ||
|
|
49ab0de7b3 | ||
|
|
0f34222954 | ||
|
|
caf5b0c9db | ||
|
|
f2d6188c53 | ||
|
|
b9af7ffc8c | ||
|
|
5bec4f3cd6 | ||
|
|
726edfa850 | ||
|
|
ff33242887 | ||
|
|
a26d5620ca | ||
|
|
8a3f1078f6 | ||
|
|
56b767ff46 | ||
|
|
102eb14b0b | ||
|
|
e57b9d07f1 | ||
|
|
3be90d00e5 | ||
|
|
efb5cd3586 | ||
|
|
86b1043134 | ||
|
|
36bed846b2 | ||
|
|
9d3d38fa7e | ||
|
|
ddf6b63aec | ||
|
|
079779c2c6 | ||
|
|
afa8bb5fe0 | ||
|
|
127668ae22 | ||
|
|
b00264d060 | ||
|
|
2e135587d4 | ||
|
|
571bffa923 | ||
|
|
bc355d43a0 | ||
|
|
e2a207be92 | ||
|
|
397cc888db | ||
|
|
22a2616534 | ||
|
|
d6c9a10766 | ||
|
|
657e8015b2 | ||
|
|
fc3612abb2 | ||
|
|
8d79a82ac2 | ||
|
|
234cf690f0 | ||
|
|
d768c8d08c | ||
|
|
e98e9cb7d9 | ||
|
|
8e8f1b3d22 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
d96000f0d9 | ||
|
|
dcad30bc39 | ||
|
|
73ee524d1f | ||
|
|
4af8334f50 | ||
|
|
43fed79204 | ||
|
|
b356814ebb | ||
|
|
0acad9927a | ||
|
|
5bc46fadfc | ||
|
|
3090306394 | ||
|
|
ec95a16c7a | ||
|
|
45d3f735a9 | ||
|
|
0734b64cc8 | ||
|
|
70ad21cb46 | ||
|
|
9181490d0f | ||
|
|
01fc5cd1a0 | ||
|
|
b12ffff310 | ||
|
|
835359edf8 | ||
|
|
88817cf95e | ||
|
|
88d41f6857 | ||
|
|
ec9c1bbbba | ||
|
|
f9313392f1 | ||
|
|
2db8af3668 | ||
|
|
c56ba6e0a1 | ||
|
|
f1dcc84991 | ||
|
|
e8aaae5616 | ||
|
|
45deb99e3d | ||
|
|
28f6f966b9 | ||
|
|
7bd569feca | ||
|
|
056f2c1833 | ||
|
|
b821d370f9 | ||
|
|
60248b28f8 | ||
|
|
d128bedffa | ||
|
|
489b545965 | ||
|
|
36533d07f8 | ||
|
|
625e4f8e6a | ||
|
|
c4774e1ce1 | ||
|
|
e1682f99d2 | ||
|
|
a23461bfce | ||
|
|
73fc36e63a | ||
|
|
4beddb7a62 | ||
|
|
b130165831 | ||
|
|
9adffc3cd7 | ||
|
|
a52619c4d5 | ||
|
|
cf40d3ad63 | ||
|
|
f7f6252d0b | ||
|
|
14a2475fb1 | ||
|
|
76a55998c2 | ||
|
|
1ec8d54e96 | ||
|
|
62395b275d | ||
|
|
57fad47f27 | ||
|
|
20c5381211 | ||
|
|
b8cd9a8c38 | ||
|
|
4335abe31b | ||
|
|
e5f7b54a7b | ||
|
|
ea1ef03b98 | ||
|
|
8d374d4f49 | ||
|
|
f910e17e53 | ||
|
|
35a76aa04f | ||
|
|
5fce21d799 | ||
|
|
a32696ee13 | ||
|
|
b573baec80 | ||
|
|
0d4feceffc | ||
|
|
92abe73f0a | ||
|
|
7fa26b0716 | ||
|
|
dc49bf3877 | ||
|
|
d825dada59 | ||
|
|
74a08732fe | ||
|
|
7033a77d71 | ||
|
|
3b26e0c014 | ||
|
|
81ec51be33 | ||
|
|
fbecda9f1e | ||
|
|
b6950d4027 | ||
|
|
f31327b528 | ||
|
|
c4c7df2608 | ||
|
|
b8bf29277a | ||
|
|
867f85e8f2 | ||
|
|
7fb98d764a | ||
|
|
792621d982 | ||
|
|
337fe21d18 | ||
|
|
c92b50b6ec | ||
|
|
f83117df20 | ||
|
|
b7b7260838 | ||
|
|
dd960d30ff | ||
|
|
89f3ec57f5 | ||
|
|
95f1e73a39 | ||
|
|
aa029fe113 | ||
|
|
5971757a28 | ||
|
|
1e16ea887b | ||
|
|
837f15c5e8 | ||
|
|
f71ff7392c | ||
|
|
97ba95e2be | ||
|
|
6aae23180f | ||
|
|
49e82e43e4 | ||
|
|
301c490893 | ||
|
|
93a9df48f4 | ||
|
|
209b91bfef | ||
|
|
1049f55118 | ||
|
|
ba7785a359 | ||
|
|
e6c821d3ee | ||
|
|
17a7741697 | ||
|
|
f00525d21a | ||
|
|
f5c79c1fab | ||
|
|
4fc0a92651 | ||
|
|
585ec39f8e | ||
|
|
a0189fdd0a | ||
|
|
ede31732b3 | ||
|
|
a60381522d | ||
|
|
64010ad86b | ||
|
|
e628154b78 | ||
|
|
e5baf5e994 | ||
|
|
05fdbab496 | ||
|
|
512b1f6455 | ||
|
|
5615d83f04 | ||
|
|
ee38918516 | ||
|
|
d1b8d86a20 | ||
|
|
25ef7c5d8a | ||
|
|
db429abf5b | ||
|
|
19d5ae7e15 | ||
|
|
fcbd613f4a | ||
|
|
5fae370c55 | ||
|
|
f2dbe6ee8f | ||
|
|
0175a6998b | ||
|
|
758de9949b | ||
|
|
81b8960d41 | ||
|
|
5b25619b24 | ||
|
|
62e23aaf23 | ||
|
|
aac8eed898 | ||
|
|
108980befb | ||
|
|
6a4cd00d51 | ||
|
|
a6c899c098 | ||
|
|
28170d31df | ||
|
|
ce8d272d6e | ||
|
|
0047685f54 | ||
|
|
2cc0fc64a4 | ||
|
|
67642cebfd | ||
|
|
327dc85d14 | ||
|
|
8c4f42bab1 | ||
|
|
40c29e494c | ||
|
|
0235ec7edc | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
a58518ccb5 | ||
|
|
cdd17d919e | ||
|
|
4580cef7f2 | ||
|
|
6f9c765bab | ||
|
|
5b56b2e0be | ||
|
|
b0cc811807 | ||
|
|
eb540d5c13 | ||
|
|
e308293cf6 | ||
|
|
9ed4659c5c | ||
|
|
f5f2b76914 | ||
|
|
551a065497 | ||
|
|
88d7e38d82 | ||
|
|
65e6cb22dd | ||
|
|
689a396f6e | ||
|
|
512ea84850 | ||
|
|
1542e583f7 | ||
|
|
c488dcc3c6 | ||
|
|
1594e0e24b | ||
|
|
1e1fa77621 | ||
|
|
3270c17514 | ||
|
|
e635942e3d | ||
|
|
64dc2858a7 | ||
|
|
d05496bb3d | ||
|
|
bb94553fff | ||
|
|
113216b7ba | ||
|
|
55181edaa8 | ||
|
|
4f01a7b577 | ||
|
|
ea21111037 | ||
|
|
fba9f1de42 | ||
|
|
7d0b8db7a6 | ||
|
|
12faa31e34 | ||
|
|
74945b1752 | ||
|
|
a7c66517d2 | ||
|
|
adf187ddf5 | ||
|
|
614d897dd2 | ||
|
|
ec9a7d68e6 | ||
|
|
79dd91b270 | ||
|
|
b26bcd7603 | ||
|
|
a65468191b | ||
|
|
4ed5271703 | ||
|
|
f2afe2a977 | ||
|
|
430b0f30c9 | ||
|
|
8aac9a795e | ||
|
|
b9405765f9 | ||
|
|
90856b3812 | ||
|
|
1e78af3c25 | ||
|
|
4be232b951 | ||
|
|
59d5c2762d | ||
|
|
21a55439ec | ||
|
|
c4a35f5c15 | ||
|
|
b42f761011 | ||
|
|
46e2e64e65 | ||
|
|
54ed35ffb3 | ||
|
|
7b8bd747ad | ||
|
|
3e379957e1 | ||
|
|
b64525487e | ||
|
|
e544f5c862 | ||
|
|
669759c52e | ||
|
|
4a13d3209b | ||
|
|
be069e9aed | ||
|
|
0b20ee1aa2 | ||
|
|
e4872a78f5 | ||
|
|
4692216325 | ||
|
|
69128062fe | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
6d652130e6 | ||
|
|
9e6f8077f7 | ||
|
|
40342ca824 | ||
|
|
71238d4a01 | ||
|
|
4da9f1e6cf | ||
|
|
93b55fe370 | ||
|
|
ee5e7d2586 | ||
|
|
4f4e09c3de | ||
|
|
d537d81f1c | ||
|
|
26c6700152 | ||
|
|
49fb96d7a3 | ||
|
|
d256ee5696 | ||
|
|
bd70a7bfa8 | ||
|
|
3fb09bad0d | ||
|
|
06079659af | ||
|
|
22d8049c2c | ||
|
|
5f6b0e8960 | ||
|
|
9b8da7774d | ||
|
|
eabed55a7a | ||
|
|
32cc74f99c | ||
|
|
ffc4cc3d96 | ||
|
|
007cf57efd | ||
|
|
c6dba71197 | ||
|
|
8aa162e294 | ||
|
|
51d6dec7ff | ||
|
|
f1b2762769 | ||
|
|
d126be2aa5 | ||
|
|
ea034ee76a | ||
|
|
39634a690c | ||
|
|
a7001eb6da | ||
|
|
71e3540f18 | ||
|
|
4ea4020faa | ||
|
|
78cadfd352 | ||
|
|
da15f829d3 | ||
|
|
bb60694013 | ||
|
|
b3758d2baf | ||
|
|
bc794e9a44 | ||
|
|
c80115d0f7 | ||
|
|
6277576249 | ||
|
|
2201d369fa | ||
|
|
9f4e4790f5 | ||
|
|
501e373e38 | ||
|
|
b2cf7c92d5 | ||
|
|
e92e13c045 | ||
|
|
f3dec958b0 | ||
|
|
c88a1c5848 | ||
|
|
0cf8ea8166 | ||
|
|
74b830dd79 | ||
|
|
8668c168a7 | ||
|
|
8b8c5f33ce | ||
|
|
2fcbb026df | ||
|
|
66ee72380d | ||
|
|
4f16345351 | ||
|
|
5110618996 | ||
|
|
bf51368cf4 | ||
|
|
d6054745d6 | ||
|
|
a4731f25f8 | ||
|
|
6c4507e495 | ||
|
|
c8e0160d5c | ||
|
|
ac40a81901 | ||
|
|
0162769d22 | ||
|
|
fa55755921 | ||
|
|
ca38a68a75 | ||
|
|
64be2dd562 | ||
|
|
ea2abb6c72 | ||
|
|
011e2ff37a | ||
|
|
cfa335564a | ||
|
|
3d1493b0a6 | ||
|
|
a46b52e603 | ||
|
|
3c0683b9f8 | ||
|
|
3214c2804e | ||
|
|
83f50cbaee | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
acec2e95a2 | ||
|
|
d26e7e78a1 | ||
|
|
77e5c44673 | ||
|
|
619cc84d15 | ||
|
|
22b85439d3 | ||
|
|
b5a371da87 |
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "dev"
|
||||
212
.github/scripts/release-utils.sh
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
retry_cmd() {
|
||||
local attempts="$1"
|
||||
local delay_seconds="$2"
|
||||
shift 2
|
||||
|
||||
local i
|
||||
local exit_code
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
exit_code=$?
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||
echo "Retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Command failed after $attempts attempts: $*" >&2
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
capture_cmd_with_retry() {
|
||||
local result_var="$1"
|
||||
local attempts="$2"
|
||||
local delay_seconds="$3"
|
||||
shift 3
|
||||
|
||||
local i
|
||||
local output=""
|
||||
local exit_code=1
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if output="$("$@" 2>/dev/null)"; then
|
||||
printf -v "$result_var" "%s" "$output"
|
||||
return 0
|
||||
fi
|
||||
exit_code=$?
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||
echo "Retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Capture command failed after $attempts attempts: $*" >&2
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
wait_for_release_id() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
local release_id
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
echo "$release_id"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
settle_release_state() {
|
||||
local repo="$1"
|
||||
local release_id="$2"
|
||||
local tag="$3"
|
||||
local attempts="${4:-12}"
|
||||
local delay_seconds="${5:-2}"
|
||||
local endpoint="repos/$repo/releases/tags/$tag"
|
||||
|
||||
local i
|
||||
local draft_state
|
||||
local prerelease_state
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to settle release state for tag '$tag'." >&2
|
||||
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_release_absent() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
return 0
|
||||
done
|
||||
|
||||
echo "Release '$tag' still exists after waiting." >&2
|
||||
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_git_tag_absent() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
return 0
|
||||
done
|
||||
|
||||
echo "Git tag '$tag' still exists after waiting." >&2
|
||||
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
recreate_fixed_prerelease() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local target_branch="$3"
|
||||
local release_title="$4"
|
||||
local release_notes="$5"
|
||||
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
|
||||
fi
|
||||
|
||||
wait_for_release_absent "$repo" "$tag" 12 2
|
||||
|
||||
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
|
||||
fi
|
||||
|
||||
wait_for_git_tag_absent "$repo" "$tag" 12 2
|
||||
|
||||
local created="false"
|
||||
local i
|
||||
for ((i = 1; i <= 6; i++)); do
|
||||
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
|
||||
created="true"
|
||||
break
|
||||
fi
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
|
||||
created="true"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -lt 6 ]; then
|
||||
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$created" != "true" ]; then
|
||||
echo "Failed to create release '$tag'." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local release_id
|
||||
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
|
||||
settle_release_state "$repo" "$release_id" "$tag" 12 2
|
||||
}
|
||||
|
||||
upload_release_assets_with_retry() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
shift 2
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "No release assets provided for upload." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
|
||||
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
|
||||
}
|
||||
134
.github/workflows/anti-spam.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Anti-Spam
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for spam
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const text = title + ' ' + body;
|
||||
|
||||
// 博彩/赌球类
|
||||
const gamblingPatterns = [
|
||||
/世界杯.*买球/, /买球.*世界杯/,
|
||||
/世界杯.*下注/, /世界杯.*竞猜/,
|
||||
/世界杯.*投注/, /世界杯.*押注/,
|
||||
/世界杯.*彩票/, /世界杯.*平台/,
|
||||
/世界杯.*app/, /世界杯.*软件/,
|
||||
/世界杯.*网站/, /世界杯.*网址/,
|
||||
/足球.*买球/, /买球.*足球/,
|
||||
/足球.*投注/, /足球.*押注/,
|
||||
/足球.*竞猜/, /足球.*平台/,
|
||||
/篮球.*买球/, /篮球.*投注/,
|
||||
/体育.*投注/, /体育.*竞猜/,
|
||||
/体育.*买球/, /体育.*押注/,
|
||||
/赌球/, /赌博.*网站/, /赌博.*平台/,
|
||||
/博彩/, /博彩.*网站/, /博彩.*平台/,
|
||||
/正规.*买球/, /官方.*买球/,
|
||||
/买球.*网站/, /买球.*app/,
|
||||
/买球.*软件/, /买球.*网址/,
|
||||
/买球.*平台/, /买球.*技巧/,
|
||||
/投注.*网站/, /投注.*平台/,
|
||||
/押注.*网站/, /押注.*平台/,
|
||||
/竞猜.*网站/, /竞猜.*平台/,
|
||||
/彩票.*网站/, /彩票.*平台/,
|
||||
/欧洲杯.*买球/, /欧冠.*买球/,
|
||||
/nba.*买球/, /nba.*投注/,
|
||||
];
|
||||
|
||||
// 色情/交友类
|
||||
const adultPatterns = [
|
||||
/约炮/, /一夜情/, /外围/,
|
||||
/包养/, /援交/, /陪聊/,
|
||||
/成人.*网站/, /成人.*视频/,
|
||||
/av.*网站/, /黄色.*网站/,
|
||||
];
|
||||
|
||||
// 贷款/金融诈骗类
|
||||
const financePatterns = [
|
||||
/秒到账.*贷款/, /无抵押.*贷款/,
|
||||
/征信.*贷款/, /黑户.*贷款/,
|
||||
/快速.*放款/, /私人.*放贷/,
|
||||
/刷单/, /兼职.*日入/, /兼职.*月入/,
|
||||
/网赚/, /躺赚/, /被动收入.*平台/,
|
||||
/虚拟货币.*投资/, /usdt.*投资/,
|
||||
/炒币.*平台/, /数字货币.*平台/,
|
||||
];
|
||||
|
||||
// 垃圾推广类
|
||||
const spamPromoPatterns = [
|
||||
/代刷/, /粉丝.*购买/, /涨粉/,
|
||||
/seo.*优化/, /快速排名/,
|
||||
/微商/, /代理.*招募/,
|
||||
];
|
||||
|
||||
// 账号特征检测(新账号 + 无 contribution)
|
||||
const allPatterns = [
|
||||
...gamblingPatterns,
|
||||
...adultPatterns,
|
||||
...financePatterns,
|
||||
...spamPromoPatterns,
|
||||
];
|
||||
|
||||
const isSpam = allPatterns.some(pattern => pattern.test(text));
|
||||
|
||||
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
|
||||
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
|
||||
|
||||
if (isSpam || suspiciousShort) {
|
||||
// 确保 spam label 存在
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'spam',
|
||||
color: 'e4e669',
|
||||
description: 'Spam issue'
|
||||
});
|
||||
} catch (e) {
|
||||
// label 已存在,忽略
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['spam']
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: 'spam'
|
||||
});
|
||||
|
||||
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
|
||||
}
|
||||
383
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,383 @@
|
||||
name: Dev Daily
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: dev-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_DEV_TAG: nightly-dev
|
||||
TARGET_BRANCH: dev
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Generate daily dev version
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Dev version: $DEV_VERSION"
|
||||
|
||||
- name: Recreate fixed prerelease
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
|
||||
|
||||
dev-mac-arm64:
|
||||
needs: prepare
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 dev artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
fi
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-linux:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux dev artifacts
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-win-x64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-win-arm64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
update-dev-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- dev-mac-arm64
|
||||
- dev-linux
|
||||
- dev-win-x64
|
||||
- dev-win-arm64
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${FIXED_DEV_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_DEV_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found, skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > dev_release_notes.md <<EOF
|
||||
## Daily Dev Build
|
||||
- 该发布页为 **开发版**。
|
||||
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
## 说明
|
||||
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
426
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,426 @@
|
||||
name: Preview Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_PREVIEW_TAG: nightly-preview
|
||||
TARGET_BRANCH: main
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.meta.outputs.should_build }}
|
||||
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Decide whether to build and generate preview version
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin main --depth=1
|
||||
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SHOULD_BUILD=true
|
||||
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||
SHOULD_BUILD=true
|
||||
else
|
||||
SHOULD_BUILD=false
|
||||
fi
|
||||
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
YEARLY_RUN_COUNT=1
|
||||
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||
|
||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||
|
||||
- name: Recreate fixed preview prerelease
|
||||
if: steps.meta.outputs.should_build == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
|
||||
|
||||
preview-mac-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 preview artifacts
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
fi
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-linux:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-win-x64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-win-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
update-preview-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- preview-mac-arm64
|
||||
- preview-linux
|
||||
- preview-win-x64
|
||||
- preview-win-arm64
|
||||
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update preview release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${FIXED_PREVIEW_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
|
||||
if [ -z "$WINDOWS_ASSET" ]; then
|
||||
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "[.]zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > preview_release_notes.md <<EOF
|
||||
## Preview Nightly 说明
|
||||
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
174
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
release-mac-arm64:
|
||||
@@ -26,7 +27,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -35,19 +35,49 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --mac dmg --arm64 --publish always
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||
fi
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
|
||||
echo "Skip $YML_FILE because download failed after retries."
|
||||
continue
|
||||
fi
|
||||
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||
fi
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||
done
|
||||
|
||||
release-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,18 +93,23 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure linux key helper is executable
|
||||
shell: bash
|
||||
run: |
|
||||
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -83,7 +118,24 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
|
||||
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
fi
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
@@ -99,7 +151,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -108,9 +159,10 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -119,7 +171,24 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
|
||||
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
fi
|
||||
|
||||
release-windows-arm64:
|
||||
runs-on: windows-latest
|
||||
@@ -135,7 +204,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -144,9 +212,10 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -155,7 +224,24 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
|
||||
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
fi
|
||||
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -172,12 +258,14 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/scripts/release-utils.sh
|
||||
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
@@ -190,7 +278,9 @@ jobs:
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||
|
||||
@@ -204,7 +294,6 @@ jobs:
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
@@ -216,20 +305,49 @@ jobs:
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
|
||||
## 下载
|
||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||
- Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示(未知来源)
|
||||
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
||||
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
||||
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||
EOF
|
||||
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
deploy-aur:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-linux]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update PKGBUILD version
|
||||
run: |
|
||||
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||
with:
|
||||
pkgname: weflow
|
||||
pkgbuild: resources/installer/linux/PKGBUILD
|
||||
updpkgsums: true
|
||||
assets: |
|
||||
resources/installer/linux/weflow.desktop
|
||||
resources/installer/linux/icon.png
|
||||
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
ssh_keyscan_types: ed25519
|
||||
|
||||
5
.gitignore
vendored
@@ -56,6 +56,8 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
!resources/wcdb/
|
||||
!resources/wcdb/**
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
@@ -71,3 +73,6 @@ resources/wx_send
|
||||
pnpm-lock.yaml
|
||||
/pnpm-workspace.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
/Wedecrypt
|
||||
23
.gitleaks.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
title = "Gitleaks Config"
|
||||
|
||||
[extend]
|
||||
# 继承默认规则
|
||||
useDefault = true
|
||||
|
||||
# 排除误报路径
|
||||
[[rules]]
|
||||
id = "curl-auth-header"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''docs/HTTP-API\.md'''
|
||||
]
|
||||
regexes = [
|
||||
'''YOUR_TOKEN'''
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''src/pages/ChatPage\.tsx'''
|
||||
]
|
||||
4
.npmrc
@@ -1,3 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
registry=https://registry.npmjs.org
|
||||
|
||||
54
README.md
@@ -1,32 +1,23 @@
|
||||
# WeFlow
|
||||
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
||||
|
||||
---
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
<p align="center">
|
||||
<img src="app.png" alt="WeFlow" width="90%">
|
||||
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
</a>
|
||||
<!-- 第一行修复样式 -->
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||
<br><br>
|
||||
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||
</p>
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
@@ -45,18 +36,18 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
|
||||
## 支持平台与设备
|
||||
|
||||
|
||||
| 平台 | 设备/架构 | 安装包 |
|
||||
|------|----------|--------|
|
||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` |
|
||||
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
|
||||
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
当前版本已支持以下能力:
|
||||
@@ -64,6 +55,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||
@@ -88,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
@@ -103,7 +94,6 @@ npm install
|
||||
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
## 致谢
|
||||
@@ -115,18 +105,16 @@ npm run dev
|
||||
|
||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
399
docs/HTTP-API.md
@@ -1,6 +1,6 @@
|
||||
# WeFlow HTTP API / Push 文档
|
||||
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
|
||||
## 启用方式
|
||||
|
||||
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||
|
||||
## 鉴权规范
|
||||
|
||||
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||
|
||||
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||
|
||||
## 接口列表
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/health`
|
||||
- `GET /api/v1/push/messages`
|
||||
- `GET /api/v1/messages`
|
||||
- `GET /api/v1/messages/new`
|
||||
- `GET /api/v1/sessions`
|
||||
- `GET /api/v1/contacts`
|
||||
- `GET /api/v1/group-members`
|
||||
- `GET /api/v1/media/*`
|
||||
- `GET|POST /health`
|
||||
- `GET|POST /api/v1/health`
|
||||
- `GET|POST /api/v1/push/messages`
|
||||
- `GET|POST /api/v1/messages`
|
||||
- `GET|POST /api/v1/sessions`
|
||||
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
|
||||
- `GET|POST /api/v1/contacts`
|
||||
- `GET|POST /api/v1/group-members`
|
||||
- `GET|POST /api/v1/media/*`
|
||||
|
||||
---
|
||||
|
||||
@@ -76,24 +86,27 @@ GET /api/v1/push/messages
|
||||
- `sourceName`
|
||||
- `groupName`(仅群聊)
|
||||
- `content`
|
||||
- `timestamp`(消息时间,秒级 Unix 时间戳)
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
示例事件:
|
||||
|
||||
```text
|
||||
event: message.new
|
||||
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
|
||||
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 获取消息
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
@@ -104,21 +117,21 @@ GET /api/v1/messages
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | ----------------------------------------------------- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
|
||||
### 示例
|
||||
|
||||
@@ -231,6 +244,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
|
||||
## 4. 获取会话列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
@@ -239,10 +254,10 @@ GET /api/v1/sessions
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | -------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -274,8 +289,134 @@ GET /api/v1/sessions
|
||||
|
||||
---
|
||||
|
||||
## 4.1 获取会话列表(ChatLab 格式)
|
||||
|
||||
当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/sessions?format=chatlab
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | -------------------------------- |
|
||||
| `format` | string | 是 | 设为 `chatlab` |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "xxx@chatroom",
|
||||
"name": "项目群",
|
||||
"platform": "wechat",
|
||||
"type": "group",
|
||||
"messageCount": 58000,
|
||||
"lastMessageAt": 1738713600
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --------------- | ----------------------------------- |
|
||||
| `id` | 会话 ID(微信 username) |
|
||||
| `name` | 会话显示名称 |
|
||||
| `platform` | 固定 `wechat` |
|
||||
| `type` | `group`(群聊)或 `private`(私聊) |
|
||||
| `messageCount` | 消息数量(估算值,可能不精确) |
|
||||
| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 4.2 拉取会话消息(ChatLab Pull)
|
||||
|
||||
返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/sessions/:id/messages
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ---------------------------------------- |
|
||||
| `:id` | string | 是 | 会话 ID(Path 参数) |
|
||||
| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
|
||||
| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
|
||||
| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
|
||||
### 响应
|
||||
|
||||
返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
|
||||
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600,
|
||||
"generator": "WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "项目群",
|
||||
"platform": "wechat",
|
||||
"type": "group",
|
||||
"groupId": "xxx@chatroom",
|
||||
"ownerId": "wxid_xxx"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_a",
|
||||
"accountName": "张三",
|
||||
"groupNickname": "产品",
|
||||
"avatar": "https://example.com/avatar.jpg"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_a",
|
||||
"accountName": "张三",
|
||||
"timestamp": 1738713600,
|
||||
"type": 0,
|
||||
"content": "你好",
|
||||
"platformMessageId": "123456"
|
||||
}
|
||||
],
|
||||
"sync": {
|
||||
"hasMore": true,
|
||||
"nextSince": 1738713600,
|
||||
"nextOffset": 5000,
|
||||
"watermark": 1738714000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### sync 块
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------ | -------------------------------- |
|
||||
| `hasMore` | 是否还有更多数据 |
|
||||
| `nextSince` | 下次请求的 `since` 值 |
|
||||
| `nextOffset` | 下次请求的 `offset` 值 |
|
||||
| `watermark` | 本次拉取的时间上界(秒级时间戳) |
|
||||
|
||||
**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。
|
||||
|
||||
---
|
||||
|
||||
## 5. 获取联系人列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
@@ -284,10 +425,10 @@ GET /api/v1/contacts
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | ---------------------------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -325,6 +466,8 @@ GET /api/v1/contacts
|
||||
|
||||
## 6. 获取群成员列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
**请求**
|
||||
@@ -335,12 +478,12 @@ GET /api/v1/group-members
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | ------------------------------- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -415,7 +558,125 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
||||
|
||||
---
|
||||
|
||||
## 7. 访问导出媒体
|
||||
## 7. 朋友圈接口
|
||||
|
||||
### 7.1 获取朋友圈时间线
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/timeline
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | ------------------------------------------------------------ |
|
||||
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||
| `keyword` | string | 否 | 关键词过滤(正文) |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
|
||||
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
|
||||
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
|
||||
```
|
||||
|
||||
媒体字段说明(`media=1`):
|
||||
|
||||
- `media[].url/thumb`:你应该优先直接使用的字段。
|
||||
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
|
||||
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
|
||||
- `media[].rawUrl/rawThumb`:原始朋友圈地址
|
||||
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
|
||||
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
|
||||
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
|
||||
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
|
||||
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
|
||||
|
||||
### 7.2 获取朋友圈发布者
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/usernames
|
||||
```
|
||||
|
||||
### 7.3 获取朋友圈导出统计
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/export/stats
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------ | ------ | ---- | ---------------------------- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
|
||||
### 7.4 朋友圈媒体代理
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/media/proxy
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----- | ------------- | ---- | ------------------------ |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
|
||||
### 7.5 导出朋友圈
|
||||
|
||||
```http
|
||||
POST /api/v1/sns/export
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
|
||||
"format": "json",
|
||||
"usernames": "wxid_a,wxid_b",
|
||||
"keyword": "旅行",
|
||||
"exportMedia": true,
|
||||
"exportImages": true,
|
||||
"exportLivePhotos": true,
|
||||
"exportVideos": true,
|
||||
"start": "20250101",
|
||||
"end": "20251231"
|
||||
}
|
||||
```
|
||||
|
||||
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||
|
||||
### 7.6 朋友圈防删开关
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/block-delete/status
|
||||
POST /api/v1/sns/block-delete/install
|
||||
POST /api/v1/sns/block-delete/uninstall
|
||||
```
|
||||
|
||||
### 7.7 删除单条朋友圈
|
||||
|
||||
```http
|
||||
DELETE /api/v1/sns/post/{postId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 访问导出媒体
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||
|
||||
@@ -436,15 +697,15 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
|
||||
### 支持的 Content-Type
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
| --- | --- |
|
||||
| `.png` | `image/png` |
|
||||
| 扩展名 | Content-Type |
|
||||
| ---------------- | ------------ |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
|
||||
常见错误响应:
|
||||
|
||||
@@ -456,24 +717,28 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
|
||||
---
|
||||
|
||||
## 8. 使用示例
|
||||
## 9. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5031/health
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
# GET 带 Token Header
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||
|
||||
# POST 带 JSON Body
|
||||
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||
```
|
||||
|
||||
### Python
|
||||
@@ -482,24 +747,26 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||
|
||||
messages = requests.get(
|
||||
# POST 方式获取消息
|
||||
messages = requests.post(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
params={"talker": "xxx@chatroom", "limit": 50}
|
||||
json={"talker": "xxx@chatroom", "limit": 50},
|
||||
headers=headers
|
||||
).json()
|
||||
|
||||
# GET 方式获取群成员
|
||||
members = requests.get(
|
||||
f"{BASE_URL}/api/v1/group-members",
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||
headers=headers
|
||||
).json()
|
||||
|
||||
print(messages)
|
||||
print(members)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 注意事项
|
||||
## 10. 注意事项
|
||||
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
|
||||
@@ -5,6 +5,9 @@ interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
@@ -29,6 +32,11 @@ async function run() {
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
})
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
|
||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
|
||||
1237
electron/main.ts
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
||||
|
||||
/**
|
||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
||||
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||
*/
|
||||
function enforceLocalDllPriority() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
||||
try {
|
||||
enforceLocalDllPriority()
|
||||
} catch (e) {
|
||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
||||
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -53,6 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
app: {
|
||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||
@@ -64,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||
},
|
||||
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
@@ -104,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
@@ -188,6 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||
checkAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
|
||||
installAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
|
||||
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
@@ -218,6 +230,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
getResourceMessages: (options?: {
|
||||
sessionId?: string
|
||||
types?: Array<'image' | 'video' | 'voice' | 'file'>
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
|
||||
getMediaStream: (options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getMediaStream', options),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||
@@ -230,6 +258,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||
getMyFootprintStats: (
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number,
|
||||
options?: {
|
||||
myWxid?: string
|
||||
privateSessionIds?: string[]
|
||||
groupSessionIds?: string[]
|
||||
mentionLimit?: number
|
||||
privateLimit?: number
|
||||
mentionMode?: 'text_at_me' | string
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
|
||||
exportMyFootprint: (
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number,
|
||||
format: 'csv' | 'json',
|
||||
filePath: string
|
||||
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
@@ -240,12 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
decrypt: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
@@ -255,12 +330,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||
ipcRenderer.on('image:cacheResolved', listener)
|
||||
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||
},
|
||||
onDecryptProgress: (callback: (payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => void) => {
|
||||
const listener = (_: unknown, payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('image:decryptProgress', listener)
|
||||
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
@@ -297,6 +393,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||
getGroupMemberMessages: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
@@ -315,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
@@ -409,7 +507,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
|
||||
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
|
||||
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
|
||||
onCacheMigrationProgress: (callback: (payload: any) => void) => {
|
||||
const listener = (_event: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('sns:cacheMigrationProgress', listener)
|
||||
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
biz: {
|
||||
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||
},
|
||||
|
||||
|
||||
@@ -422,8 +535,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
},
|
||||
|
||||
// AI 见解
|
||||
insight: {
|
||||
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
private_inbound_people?: number
|
||||
private_replied_people?: number
|
||||
private_outbound_people?: number
|
||||
private_reply_rate?: number
|
||||
mention_count?: number
|
||||
mention_group_count?: number
|
||||
}
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||
},
|
||||
|
||||
social: {
|
||||
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
|
||||
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface AnnualReportData {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed: {
|
||||
avgResponseTime: number
|
||||
@@ -1135,7 +1137,7 @@ class AnnualReportService {
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastProgressAt > 200) {
|
||||
let progress = 30
|
||||
let progress: number
|
||||
if (totalMessagesForProgress > 0) {
|
||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||
progress = 30 + Math.floor(ratio * 50)
|
||||
@@ -1190,7 +1192,9 @@ class AnnualReportService {
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
} | undefined
|
||||
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||
const snsBeginTime = isAllTime ? 0 : actualStartTime
|
||||
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
|
||||
|
||||
if (snsStats.success && snsStats.data) {
|
||||
const d = snsStats.data
|
||||
@@ -1217,6 +1221,20 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。
|
||||
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
|
||||
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
|
||||
if (snsExportStats.success && snsExportStats.data) {
|
||||
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
|
||||
snsStatsResult = {
|
||||
totalPosts: fallbackTotalPosts,
|
||||
typeCounts: snsStatsResult?.typeCounts,
|
||||
topLikers: snsStatsResult?.topLikers || [],
|
||||
topLiked: snsStatsResult?.topLiked || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||
|
||||
const contactIds = Array.from(contactStats.keys())
|
||||
@@ -1346,16 +1364,27 @@ class AnnualReportService {
|
||||
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
||||
let totalInitiated = 0
|
||||
let totalReceived = 0
|
||||
for (const stats of conversationStarts.values()) {
|
||||
let topInitiatedSessionId = ''
|
||||
let topInitiatedCount = 0
|
||||
for (const [sessionId, stats] of conversationStarts.entries()) {
|
||||
totalInitiated += stats.initiated
|
||||
totalReceived += stats.received
|
||||
if (stats.initiated > topInitiatedCount) {
|
||||
topInitiatedCount = stats.initiated
|
||||
topInitiatedSessionId = sessionId
|
||||
}
|
||||
}
|
||||
const totalConversations = totalInitiated + totalReceived
|
||||
if (totalConversations > 0) {
|
||||
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
|
||||
socialInitiative = {
|
||||
initiatedChats: totalInitiated,
|
||||
receivedChats: totalReceived,
|
||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
|
||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
|
||||
topInitiatedFriend: topInitiatedCount > 0
|
||||
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
|
||||
: undefined,
|
||||
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "./config";
|
||||
|
||||
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||
export class AvatarFileCacheService {
|
||||
private static instance: AvatarFileCacheService | null = null;
|
||||
|
||||
// 头像文件缓存目录
|
||||
private readonly cacheDir: string;
|
||||
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||
new Map();
|
||||
// LRU 追踪:文件路径->最后访问时间
|
||||
private readonly lruOrder: string[] = [];
|
||||
private readonly maxCacheFiles = 100;
|
||||
|
||||
private constructor() {
|
||||
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||
this.cacheDir = join(basePath, "avatar-files");
|
||||
this.ensureCacheDir();
|
||||
this.loadLruOrder();
|
||||
}
|
||||
|
||||
public static getInstance(): AvatarFileCacheService {
|
||||
if (!AvatarFileCacheService.instance) {
|
||||
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||
}
|
||||
return AvatarFileCacheService.instance;
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
// 同步确保目录存在(构造函数调用)
|
||||
try {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async ensureCacheDirAsync(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getFilePath(url: string): string {
|
||||
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||
const hash = this.hashString(url);
|
||||
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
private async loadLruOrder(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
// 按修改时间排序(旧的在前)
|
||||
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||
this.lruOrder.length = 0;
|
||||
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private updateLru(fileName: string): void {
|
||||
const index = this.lruOrder.indexOf(fileName);
|
||||
if (index > -1) {
|
||||
this.lruOrder.splice(index, 1);
|
||||
}
|
||||
this.lruOrder.push(fileName);
|
||||
}
|
||||
|
||||
private async evictIfNeeded(): Promise<void> {
|
||||
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||
const oldest = this.lruOrder.shift();
|
||||
if (oldest) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, oldest));
|
||||
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAvatar(url: string): Promise<string | null> {
|
||||
const localPath = this.getFilePath(url);
|
||||
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.access(localPath);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
return localPath;
|
||||
} catch {}
|
||||
|
||||
await this.ensureCacheDirAsync();
|
||||
await this.evictIfNeeded();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const options = {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
Referer: "https://servicewechat.com/",
|
||||
Accept:
|
||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
};
|
||||
|
||||
const callback = (res: IncomingMessage) => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.writeFile(localPath, buffer);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
console.log(
|
||||
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||
);
|
||||
resolve(localPath);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", () => resolve(null));
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取头像本地文件路径,如果需要会下载
|
||||
* 同一URL并发调用会复用同一个下载任务
|
||||
*/
|
||||
async getAvatarPath(url: string): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
// 检查是否有正在进行的下载
|
||||
const pending = this.pendingDownloads.get(url);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// 发起新下载
|
||||
const downloadPromise = this.downloadAvatar(url);
|
||||
this.pendingDownloads.set(url, downloadPromise);
|
||||
|
||||
try {
|
||||
const result = await downloadPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingDownloads.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有缓存文件(App退出时调用)
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, entry));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.lruOrder.length = 0;
|
||||
console.log("[AvatarFileCache] Cache cleared");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 获取当前缓存的文件数量
|
||||
async getCacheCount(): Promise<number> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
return entries.filter(
|
||||
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||
).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||
250
electron/services/bizService.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { join } from 'path'
|
||||
import { readdirSync, existsSync } from 'fs'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { ipcMain } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface BizAccount {
|
||||
username: string
|
||||
name: string
|
||||
avatar: string
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
unread_count?: number
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
des: string
|
||||
url: string
|
||||
cover: string
|
||||
content_list: any[]
|
||||
}
|
||||
|
||||
export interface BizPayRecord {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
description: string
|
||||
merchant_name: string
|
||||
merchant_icon: string
|
||||
timestamp: number
|
||||
formatted_time: string
|
||||
}
|
||||
|
||||
export class BizService {
|
||||
private configService: ConfigService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private extractXmlValue(xml: string, tagName: string): string {
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||
const match = regex.exec(xml)
|
||||
if (match) {
|
||||
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseBizContentList(xmlStr: string): any[] {
|
||||
if (!xmlStr) return []
|
||||
const contentList: any[] = []
|
||||
try {
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||
const itemXml = match[1]
|
||||
const itemStruct = {
|
||||
title: this.extractXmlValue(itemXml, 'title'),
|
||||
url: this.extractXmlValue(itemXml, 'url'),
|
||||
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||
}
|
||||
if (itemStruct.title) contentList.push(itemStruct)
|
||||
}
|
||||
} catch (e) { }
|
||||
return contentList
|
||||
}
|
||||
|
||||
private parsePayXml(xmlStr: string): any {
|
||||
if (!xmlStr) return null
|
||||
try {
|
||||
const title = this.extractXmlValue(xmlStr, 'title')
|
||||
const description = this.extractXmlValue(xmlStr, 'des')
|
||||
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||
if (!title && !description) return null
|
||||
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||
} catch (e) { return null }
|
||||
}
|
||||
|
||||
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||
try {
|
||||
// 1. 获取公众号联系人列表
|
||||
const contactsResult = await chatService.getContacts({ lite: true })
|
||||
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||
|
||||
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||
const usernames = officialContacts.map(c => c.username)
|
||||
|
||||
// 获取头像和昵称等补充信息
|
||||
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||
|
||||
const root = this.configService.get('dbPath')
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const accountWxid = account || myWxid
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
const bizUnreadCount: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await chatService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||
// 适配日志中发现的字段,注意转为整型数字
|
||||
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const time = parseInt(timeStr.toString(), 10)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
bizLatestTime[uname] = time
|
||||
}
|
||||
if (usernames.includes(uname)) {
|
||||
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取 Sessions 失败:', e)
|
||||
}
|
||||
|
||||
// 3. 格式化时间显示
|
||||
const formatBizTime = (ts: number) => {
|
||||
if (!ts) return ''
|
||||
const date = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
|
||||
const isThisYear = date.getFullYear() === now.getFullYear()
|
||||
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
|
||||
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 4. 组装数据
|
||||
const result: BizAccount[] = officialContacts.map(contact => {
|
||||
const uname = contact.username
|
||||
const info = contactInfoMap[uname]
|
||||
const lastTime = bizLatestTime[uname] || 0
|
||||
return {
|
||||
username: uname,
|
||||
name: info?.displayName || contact.displayName || uname,
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime),
|
||||
unread_count: bizUnreadCount[uname] || 0
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 补充公众号类型 (订阅号/服务号)
|
||||
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
|
||||
if (existsSync(contactDbPath)) {
|
||||
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||
const typeMap: Record<string, number> = {}
|
||||
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 排序输出
|
||||
return result
|
||||
.filter(acc => !acc.name.includes('广告'))
|
||||
.sort((a, b) => {
|
||||
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
|
||||
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||
return b.last_time - a.last_time // 按最新时间降序排列
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('获取账号列表发生错误:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||
try {
|
||||
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
return res.messages.map(msg => {
|
||||
const bizMsg: BizMessage = {
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
title: msg.linkTitle || msg.parsedContent || '',
|
||||
des: msg.appMsgDesc || '',
|
||||
url: msg.linkUrl || '',
|
||||
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||
content_list: []
|
||||
}
|
||||
if (msg.rawContent) {
|
||||
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||
bizMsg.title = bizMsg.content_list[0].title
|
||||
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||
}
|
||||
}
|
||||
return bizMsg
|
||||
})
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||
const username = 'gh_3dfda90e39d6'
|
||||
try {
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
const records: BizPayRecord[] = []
|
||||
for (const msg of res.messages) {
|
||||
if (!msg.rawContent) continue
|
||||
const parsedData = this.parsePayXml(msg.rawContent)
|
||||
if (parsedData) {
|
||||
records.push({
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
...parsedData,
|
||||
timestamp: parsedData.timestamp || msg.createTime,
|
||||
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return records
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
registerHandlers() {
|
||||
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||
}
|
||||
}
|
||||
|
||||
export const bizService = new BizService()
|
||||
@@ -15,15 +15,31 @@ class CloudControlService {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
private platformVersionCache: string | null = null
|
||||
private pendingReports: UsageStats[] = []
|
||||
private flushInProgress = false
|
||||
private retryDelayMs = 5_000
|
||||
private consecutiveFailures = 0
|
||||
private circuitOpenedAt = 0
|
||||
private nextDelayOverrideMs: number | null = null
|
||||
private initialized = false
|
||||
|
||||
private static readonly BASE_FLUSH_MS = 300_000
|
||||
private static readonly JITTER_MS = 30_000
|
||||
private static readonly MAX_BUFFER_REPORTS = 200
|
||||
private static readonly MAX_BATCH_REPORTS = 20
|
||||
private static readonly MAX_RETRY_MS = 120_000
|
||||
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
|
||||
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
this.deviceId = this.getDeviceId()
|
||||
await wcdbService.cloudInit(300)
|
||||
await this.reportOnline()
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.reportOnline()
|
||||
}, 300000)
|
||||
this.enqueueCurrentReport()
|
||||
await this.flushQueue(true)
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
}
|
||||
|
||||
private getDeviceId(): string {
|
||||
@@ -33,8 +49,8 @@ class CloudControlService {
|
||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||
}
|
||||
|
||||
private async reportOnline() {
|
||||
const data: UsageStats = {
|
||||
private buildCurrentReport(): UsageStats {
|
||||
return {
|
||||
appVersion: app.getVersion(),
|
||||
platform: this.getPlatformVersion(),
|
||||
deviceId: this.deviceId,
|
||||
@@ -42,11 +58,69 @@ class CloudControlService {
|
||||
online: true,
|
||||
pages: Array.from(this.pages)
|
||||
}
|
||||
}
|
||||
|
||||
await wcdbService.cloudReport(JSON.stringify(data))
|
||||
private enqueueCurrentReport() {
|
||||
const report = this.buildCurrentReport()
|
||||
this.pendingReports.push(report)
|
||||
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
|
||||
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
|
||||
}
|
||||
this.pages.clear()
|
||||
}
|
||||
|
||||
private isCircuitOpen(nowMs: number): boolean {
|
||||
if (this.circuitOpenedAt <= 0) return false
|
||||
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
|
||||
}
|
||||
|
||||
private scheduleNextFlush(delayMs?: number) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
|
||||
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
|
||||
this.timer = setTimeout(() => {
|
||||
this.enqueueCurrentReport()
|
||||
this.flushQueue(false).finally(() => {
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
})
|
||||
}, nextDelay)
|
||||
}
|
||||
|
||||
private async flushQueue(force: boolean) {
|
||||
if (this.flushInProgress) return
|
||||
if (this.pendingReports.length === 0) return
|
||||
const now = Date.now()
|
||||
if (!force && this.isCircuitOpen(now)) {
|
||||
return
|
||||
}
|
||||
this.flushInProgress = true
|
||||
try {
|
||||
while (this.pendingReports.length > 0) {
|
||||
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
|
||||
const result = await wcdbService.cloudReport(JSON.stringify(batch))
|
||||
if (!result || result.success !== true) {
|
||||
this.consecutiveFailures += 1
|
||||
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
|
||||
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
|
||||
this.circuitOpenedAt = Date.now()
|
||||
}
|
||||
this.nextDelayOverrideMs = this.retryDelayMs
|
||||
return
|
||||
}
|
||||
this.pendingReports.splice(0, batch.length)
|
||||
this.consecutiveFailures = 0
|
||||
this.retryDelayMs = 5_000
|
||||
this.circuitOpenedAt = 0
|
||||
}
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
if (this.platformVersionCache) {
|
||||
return this.platformVersionCache
|
||||
@@ -144,12 +218,25 @@ class CloudControlService {
|
||||
this.pages.add(pageName)
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
wcdbService.cloudStop()
|
||||
this.pendingReports = []
|
||||
this.flushInProgress = false
|
||||
this.retryDelayMs = 5_000
|
||||
this.consecutiveFailures = 0
|
||||
this.circuitOpenedAt = 0
|
||||
this.nextDelayOverrideMs = null
|
||||
this.initialized = false
|
||||
if (wcdbService.isReady()) {
|
||||
try {
|
||||
await wcdbService.cloudStop()
|
||||
} catch {
|
||||
// 忽略停止失败,避免阻塞主进程退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
@@ -158,4 +245,3 @@ class CloudControlService {
|
||||
}
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { join } from 'path'
|
||||
import { join } from 'path'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const isSafeStorageAvailable = (): boolean => {
|
||||
try {
|
||||
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
@@ -27,6 +35,7 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -34,7 +43,6 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -45,6 +53,7 @@ interface ConfigSchema {
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
@@ -52,13 +61,66 @@ interface ConfigSchema {
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
messagePushFilterList: string[]
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiHost: string
|
||||
httpApiToken: string
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
exportWriteLayout: 'A' | 'B' | 'C'
|
||||
exportAutomationTaskMap: Record<string, unknown>
|
||||
|
||||
// AI 见解
|
||||
aiModelApiBaseUrl: string
|
||||
aiModelApiKey: string
|
||||
aiModelApiModel: string
|
||||
aiModelApiMaxTokens: number
|
||||
aiInsightEnabled: boolean
|
||||
aiInsightApiBaseUrl: string
|
||||
aiInsightApiKey: string
|
||||
aiInsightApiModel: string
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightAllowSocialContext: boolean
|
||||
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||
aiInsightFilterList: string[]
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
aiInsightCooldownMinutes: number
|
||||
/** 沉默联系人扫描间隔(小时) */
|
||||
aiInsightScanIntervalHours: number
|
||||
/** 发送上下文时的最大消息条数 */
|
||||
aiInsightContextCount: number
|
||||
/** 自定义 system prompt,空字符串表示使用内置默认值 */
|
||||
aiInsightSystemPrompt: string
|
||||
/** 是否启用 Telegram 推送 */
|
||||
aiInsightTelegramEnabled: boolean
|
||||
/** Telegram Bot Token */
|
||||
aiInsightTelegramToken: string
|
||||
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
|
||||
aiInsightTelegramChatIds: string
|
||||
|
||||
// AI 足迹
|
||||
aiFootprintEnabled: boolean
|
||||
aiFootprintSystemPrompt: string
|
||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||
aiInsightDebugLogEnabled: boolean
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||
'decryptKey',
|
||||
'imageAesKey',
|
||||
'authPassword',
|
||||
'httpApiToken',
|
||||
'aiModelApiKey',
|
||||
'aiInsightApiKey',
|
||||
'aiInsightWeiboCookie'
|
||||
])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
@@ -108,21 +170,57 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
exportDefaultImageDeepSearchOnMiss: true,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
updateChannel: 'auto',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
httpApiToken: '',
|
||||
httpApiEnabled: false,
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
messagePushEnabled: false,
|
||||
messagePushFilterMode: 'all',
|
||||
messagePushFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: []
|
||||
wordCloudExcludeWords: [],
|
||||
exportWriteLayout: 'A',
|
||||
exportAutomationTaskMap: {},
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
aiModelApiModel: 'gpt-4o-mini',
|
||||
aiModelApiMaxTokens: 200,
|
||||
aiInsightEnabled: false,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightAllowSocialContext: false,
|
||||
aiInsightFilterMode: 'whitelist',
|
||||
aiInsightFilterList: [],
|
||||
aiInsightWhitelistEnabled: false,
|
||||
aiInsightWhitelist: [],
|
||||
aiInsightCooldownMinutes: 120,
|
||||
aiInsightScanIntervalHours: 4,
|
||||
aiInsightContextCount: 40,
|
||||
aiInsightSocialContextCount: 3,
|
||||
aiInsightSystemPrompt: '',
|
||||
aiInsightTelegramEnabled: false,
|
||||
aiInsightTelegramToken: '',
|
||||
aiInsightTelegramChatIds: '',
|
||||
aiInsightWeiboCookie: '',
|
||||
aiInsightWeiboBindings: {},
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
@@ -154,6 +252,7 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
this.migrateAuthFields()
|
||||
this.migrateAiConfig()
|
||||
}
|
||||
|
||||
// === 状态查询 ===
|
||||
@@ -203,6 +302,10 @@ export class ConfigService {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'dbPath' && typeof raw === 'string') {
|
||||
return expandHomePath(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -210,8 +313,14 @@ export class ConfigService {
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (key === 'dbPath' && typeof value === 'string') {
|
||||
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
@@ -244,7 +353,7 @@ export class ConfigService {
|
||||
private safeEncrypt(plaintext: string): string {
|
||||
if (!plaintext) return ''
|
||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||
if (!isSafeStorageAvailable()) return plaintext
|
||||
const encrypted = safeStorage.encryptString(plaintext)
|
||||
return SAFE_PREFIX + encrypted.toString('base64')
|
||||
}
|
||||
@@ -252,7 +361,7 @@ export class ConfigService {
|
||||
private safeDecrypt(stored: string): string {
|
||||
if (!stored) return ''
|
||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||
if (!isSafeStorageAvailable()) return ''
|
||||
try {
|
||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||
return safeStorage.decryptString(buf)
|
||||
@@ -590,7 +699,7 @@ export class ConfigService {
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
@@ -599,13 +708,18 @@ export class ConfigService {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
if (rawEnabled === true || rawEnabled === 'true') {
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||
this.store.set('authEnabled', false as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
if (rawUseHello === true || rawUseHello === 'true') {
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
@@ -651,6 +765,26 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAiConfig(): void {
|
||||
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
|
||||
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
|
||||
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
|
||||
|
||||
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
|
||||
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
|
||||
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
|
||||
|
||||
if (!sharedBaseUrl && legacyBaseUrl) {
|
||||
this.set('aiModelApiBaseUrl', legacyBaseUrl)
|
||||
}
|
||||
if (!sharedApiKey && legacyApiKey) {
|
||||
this.set('aiModelApiKey', legacyApiKey)
|
||||
}
|
||||
if (!sharedModel && legacyModel) {
|
||||
this.set('aiModelApiModel', legacyModel)
|
||||
}
|
||||
}
|
||||
|
||||
// === 验证 ===
|
||||
|
||||
verifyAuthEnabled(): boolean {
|
||||
@@ -662,11 +796,9 @@ export class ConfigService {
|
||||
|
||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// === 工具方法 ===
|
||||
@@ -714,3 +846,4 @@ export class ConfigService {
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
@@ -93,27 +94,39 @@ export class DbPathService {
|
||||
const possiblePaths: string[] = []
|
||||
const home = homedir()
|
||||
|
||||
// macOS 微信路径(固定)
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS 微信 4.0.5+ 新路径(优先检测)
|
||||
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||
if (existsSync(appSupportBase)) {
|
||||
try {
|
||||
const entries = readdirSync(appSupportBase)
|
||||
for (const entry of entries) {
|
||||
// 匹配形如 2.0b4.0.9 的版本目录
|
||||
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||
possiblePaths.push(join(appSupportBase, entry))
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
// macOS 旧路径兜底
|
||||
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||
} else {
|
||||
// Windows 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
}
|
||||
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
if (existsSync(path)) {
|
||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
||||
continue
|
||||
}
|
||||
if (!existsSync(path)) continue
|
||||
|
||||
// 检查是否有有效的账号目录
|
||||
const accounts = this.findAccountDirs(path)
|
||||
if (accounts.length > 0) {
|
||||
return { success: true, path }
|
||||
}
|
||||
// 检查是否有有效的账号目录,或本身就是账号目录
|
||||
const accounts = this.findAccountDirs(path)
|
||||
if (accounts.length > 0) {
|
||||
return { success: true, path }
|
||||
}
|
||||
|
||||
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
|
||||
if (this.isAccountDir(path)) {
|
||||
return { success: true, path }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,13 +140,14 @@ export class DbPathService {
|
||||
* 查找账号目录(包含 db_storage 或图片目录)
|
||||
*/
|
||||
findAccountDirs(rootPath: string): string[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const accounts: string[] = []
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
@@ -204,13 +218,14 @@ export class DbPathService {
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(rootPath)) {
|
||||
const entries = readdirSync(rootPath)
|
||||
if (existsSync(resolvedRootPath)) {
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
@@ -223,9 +238,9 @@ export class DbPathService {
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
const rootName = basename(resolvedRootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
const rootStat = statSync(rootPath)
|
||||
const rootStat = statSync(resolvedRootPath)
|
||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||
}
|
||||
}
|
||||
@@ -236,7 +251,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
@@ -254,19 +269,20 @@ export class DbPathService {
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
scanWxids(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (this.isAccountDir(rootPath)) {
|
||||
const wxid = basename(rootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
||||
if (this.isAccountDir(resolvedRootPath)) {
|
||||
const wxid = basename(resolvedRootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||
return [{ wxid, modifiedTime }]
|
||||
}
|
||||
|
||||
const accounts = this.findAccountDirs(rootPath)
|
||||
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||
|
||||
for (const account of accounts) {
|
||||
const fullPath = join(rootPath, account)
|
||||
const fullPath = join(resolvedRootPath, account)
|
||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||
wxids.push({ wxid: account, modifiedTime })
|
||||
}
|
||||
@@ -277,7 +293,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
@@ -295,6 +311,20 @@ export class DbPathService {
|
||||
getDefaultPath(): string {
|
||||
const home = homedir()
|
||||
if (process.platform === 'darwin') {
|
||||
// 优先返回 4.0.5+ 新路径
|
||||
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||
if (existsSync(appSupportBase)) {
|
||||
try {
|
||||
const entries = readdirSync(appSupportBase)
|
||||
for (const entry of entries) {
|
||||
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||
const candidate = join(appSupportBase, entry)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
// 旧版本路径兜底
|
||||
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||
}
|
||||
return join(home, 'Documents', 'xwechat_files')
|
||||
|
||||
@@ -19,7 +19,8 @@ class ExportRecordService {
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app.getPath('userData')
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||
return this.filePath
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { chatService } from './chatService'
|
||||
import type { Message } from './chatService'
|
||||
import type { ChatStatistics } from './analyticsService'
|
||||
|
||||
export interface GroupChatInfo {
|
||||
username: string
|
||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GroupMemberAnalytics {
|
||||
statistics: ChatStatistics
|
||||
timeDistribution: Record<number, number>
|
||||
commonPhrases?: Array<{ phrase: string; count: number }>
|
||||
commonEmojis?: Array<{ emoji: string; count: number }>
|
||||
}
|
||||
|
||||
export interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
@@ -267,7 +275,7 @@ class GroupAnalyticsService {
|
||||
}
|
||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
console.error('getGroupNicknamesForRoom service error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
@@ -797,7 +805,12 @@ class GroupAnalyticsService {
|
||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||
}
|
||||
|
||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
||||
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
|
||||
return myWxid
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
row.sender_username,
|
||||
row.senderUsername,
|
||||
@@ -820,13 +833,33 @@ class GroupAnalyticsService {
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fast extract from raw content to avoid full parse
|
||||
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
|
||||
if (rawContent) {
|
||||
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||
if (Array.isArray(mapped) && mapped.length > 0) {
|
||||
const msg = mapped[0]
|
||||
if (!msg.localType) {
|
||||
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
}
|
||||
if (!msg.createTime) {
|
||||
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -881,7 +914,7 @@ class GroupAnalyticsService {
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
@@ -987,7 +1020,7 @@ class GroupAnalyticsService {
|
||||
const row = rows[index]
|
||||
consumedRows += 1
|
||||
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
@@ -1467,6 +1500,154 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMemberAnalytics(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
|
||||
const batchSize = 10000
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
const stats: ChatStatistics = {
|
||||
totalMessages: 0,
|
||||
textMessages: 0,
|
||||
imageMessages: 0,
|
||||
voiceMessages: 0,
|
||||
videoMessages: 0,
|
||||
emojiMessages: 0,
|
||||
otherMessages: 0,
|
||||
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
|
||||
receivedMessages: 0, // No meaning here
|
||||
firstMessageTime: null,
|
||||
lastMessageTime: null,
|
||||
activeDays: 0,
|
||||
messageTypeCounts: {}
|
||||
}
|
||||
|
||||
const hourlyDistribution: Record<number, number> = {}
|
||||
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
|
||||
const dailySet = new Set<string>()
|
||||
const textTypes = [1, 244813135921]
|
||||
|
||||
const phraseCounts = new Map<string, number>()
|
||||
const emojiCounts = new Map<string, number>()
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取分析数据失败' }
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
|
||||
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
|
||||
|
||||
if (isSend) {
|
||||
senderFromRow = myWxid
|
||||
}
|
||||
|
||||
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
|
||||
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
|
||||
if (content) {
|
||||
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
|
||||
}
|
||||
|
||||
stats.totalMessages++
|
||||
if (textTypes.includes(msgType)) {
|
||||
stats.textMessages++
|
||||
if (content) {
|
||||
const text = content.trim()
|
||||
if (text && text.length <= 20) {
|
||||
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
|
||||
}
|
||||
const emojiMatches = text.match(/\[.*?\]/g)
|
||||
if (emojiMatches) {
|
||||
for (const em of emojiMatches) {
|
||||
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msgType === 3) stats.imageMessages++
|
||||
else if (msgType === 34) stats.voiceMessages++
|
||||
else if (msgType === 43) stats.videoMessages++
|
||||
else if (msgType === 47) stats.emojiMessages++
|
||||
else stats.otherMessages++
|
||||
|
||||
stats.sentMessages++
|
||||
|
||||
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
|
||||
|
||||
if (createTime > 0) {
|
||||
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
|
||||
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
|
||||
|
||||
const d = new Date(createTime * 1000)
|
||||
const hour = d.getHours()
|
||||
hourlyDistribution[hour]++
|
||||
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
}
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
stats.activeDays = dailySet.size
|
||||
|
||||
const commonPhrases = Array.from(phraseCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
const commonEmojis = Array.from(emojiCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([emoji, count]) => ({ emoji, count }))
|
||||
|
||||
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
|
||||
@@ -6,12 +6,14 @@ import * as http from 'http'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||
import { snsService } from './snsService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -101,6 +103,7 @@ class HttpService {
|
||||
private server: http.Server | null = null
|
||||
private configService: ConfigService
|
||||
private port: number = 5031
|
||||
private host: string = '127.0.0.1'
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||
@@ -114,12 +117,13 @@ class HttpService {
|
||||
/**
|
||||
* 启动 HTTP 服务
|
||||
*/
|
||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||
if (this.running && this.server) {
|
||||
return { success: true, port: this.port }
|
||||
}
|
||||
|
||||
this.port = port
|
||||
this.host = host
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||
@@ -153,10 +157,10 @@ class HttpService {
|
||||
}
|
||||
})
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
this.server.listen(this.port, this.host, () => {
|
||||
this.running = true
|
||||
this.startMessagePushHeartbeat()
|
||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
|
||||
resolve({ success: true, port: this.port })
|
||||
})
|
||||
})
|
||||
@@ -225,7 +229,7 @@ class HttpService {
|
||||
}
|
||||
|
||||
getMessagePushStreamUrl(): string {
|
||||
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
||||
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
||||
}
|
||||
|
||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||
@@ -246,49 +250,178 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 设置 CORS 头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
// 路由处理
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/push/messages') {
|
||||
this.handleMessagePushStream(req, res)
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
async autoStart(): Promise<void> {
|
||||
const enabled = this.configService.get('httpApiEnabled')
|
||||
if (enabled) {
|
||||
const port = Number(this.configService.get('httpApiPort')) || 5031
|
||||
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
|
||||
try {
|
||||
await this.start(port, host)
|
||||
console.log(`[HttpService] Auto-started on port ${port}`)
|
||||
} catch (err) {
|
||||
console.error('[HttpService] Auto-start failed:', err)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Request error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 POST 请求的 JSON Body
|
||||
*/
|
||||
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||
if (req.method !== 'POST') return {}
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
return new Promise((resolve) => {
|
||||
let body = ''
|
||||
let bodySize = 0
|
||||
req.on('data', chunk => {
|
||||
bodySize += chunk.length
|
||||
if (bodySize > MAX_BODY_SIZE) {
|
||||
req.destroy()
|
||||
resolve({})
|
||||
return
|
||||
}
|
||||
body += chunk.toString()
|
||||
})
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch {
|
||||
resolve({})
|
||||
}
|
||||
})
|
||||
req.on('error', () => resolve({}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权拦截器
|
||||
*/
|
||||
private safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||
if (!expectedToken) {
|
||||
// token 未配置时拒绝所有请求,防止未授权访问
|
||||
console.warn('[HttpService] Access denied: httpApiToken not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7).trim()
|
||||
if (this.safeEqual(token, expectedToken)) return true
|
||||
}
|
||||
|
||||
const queryToken = url.searchParams.get('access_token')
|
||||
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
|
||||
|
||||
const bodyToken = body['access_token']
|
||||
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求 (重构后)
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 仅允许本地来源的跨域请求
|
||||
const origin = req.headers.origin || ''
|
||||
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
const bodyParams = await this.parseBody(req)
|
||||
|
||||
for (const [key, value] of Object.entries(bodyParams)) {
|
||||
if (!url.searchParams.has(key)) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||
if (!this.verifyToken(req, url, bodyParams)) {
|
||||
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/push/messages') {
|
||||
this.handleMessagePushStream(req, res)
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (
|
||||
pathname.startsWith('/api/v1/sessions/') &&
|
||||
pathname.endsWith('/messages')
|
||||
) {
|
||||
const parts = pathname.split('/')
|
||||
const sessionId = decodeURIComponent(parts[4] || '')
|
||||
if (!sessionId) {
|
||||
this.sendError(res, 400, 'Missing session ID')
|
||||
} else {
|
||||
await this.handlePullMessages(sessionId, url, res)
|
||||
}
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname === '/api/v1/sns/timeline') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsTimeline(url, res)
|
||||
} else if (pathname === '/api/v1/sns/usernames') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsUsernames(res)
|
||||
} else if (pathname === '/api/v1/sns/export/stats') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsExportStats(url, res)
|
||||
} else if (pathname === '/api/v1/sns/media/proxy') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsMediaProxy(url, res)
|
||||
} else if (pathname === '/api/v1/sns/export') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsExport(url, res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/status') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsBlockDeleteStatus(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/install') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteInstall(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteUninstall(res)
|
||||
} else if (pathname.startsWith('/api/v1/sns/post/')) {
|
||||
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
|
||||
await this.handleSnsDeletePost(pathname, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Request error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
private startMessagePushHeartbeat(): void {
|
||||
if (this.messagePushHeartbeatTimer) return
|
||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||
@@ -334,9 +467,15 @@ class HttpService {
|
||||
}
|
||||
|
||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||
const mediaBasePath = this.getApiMediaExportPath()
|
||||
const mediaBasePath = path.resolve(this.getApiMediaExportPath())
|
||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||
const fullPath = path.join(mediaBasePath, relativePath)
|
||||
const fullPath = path.resolve(mediaBasePath, relativePath)
|
||||
|
||||
// 防止路径穿越攻击
|
||||
if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) {
|
||||
this.sendError(res, 403, 'Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
this.sendError(res, 404, 'Media not found')
|
||||
@@ -490,6 +629,15 @@ class HttpService {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private parseStringListParam(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
const values = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
return values.length > 0 ? Array.from(new Set(values)) : undefined
|
||||
}
|
||||
|
||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||
if (!mediaEnabled) {
|
||||
@@ -599,6 +747,7 @@ class HttpService {
|
||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
const format = (url.searchParams.get('format') || '').trim().toLowerCase()
|
||||
|
||||
try {
|
||||
const sessions = await chatService.getSessions()
|
||||
@@ -616,9 +765,22 @@ class HttpService {
|
||||
)
|
||||
}
|
||||
|
||||
// 应用 limit
|
||||
const limitedSessions = filteredSessions.slice(0, limit)
|
||||
|
||||
if (format === 'chatlab') {
|
||||
this.sendJson(res, {
|
||||
sessions: limitedSessions.map(s => ({
|
||||
id: s.username,
|
||||
name: s.displayName || s.username,
|
||||
platform: 'wechat',
|
||||
type: s.username.endsWith('@chatroom') ? 'group' : 'private',
|
||||
messageCount: s.messageCountHint || undefined,
|
||||
lastMessageAt: s.lastTimestamp
|
||||
}))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limitedSessions.length,
|
||||
@@ -635,6 +797,53 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatLab Pull: GET /api/v1/sessions/:id/messages?since=&limit=&offset=&end=
|
||||
* 返回 ChatLab 标准格式 + sync 分页块
|
||||
*/
|
||||
private async handlePullMessages(sessionId: string, url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const PULL_MAX_LIMIT = 5000
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), PULL_MAX_LIMIT, 1, PULL_MAX_LIMIT)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const sinceParam = url.searchParams.get('since')
|
||||
const endParam = url.searchParams.get('end')
|
||||
|
||||
const startTime = sinceParam ? this.parseTimeParam(sinceParam) : 0
|
||||
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
||||
|
||||
try {
|
||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
|
||||
const messages = result.messages
|
||||
const hasMore = result.hasMore === true
|
||||
|
||||
const displayNames = await this.getDisplayNames([sessionId])
|
||||
const talkerName = displayNames[sessionId] || sessionId
|
||||
const chatLabData = await this.convertToChatLab(messages, sessionId, talkerName)
|
||||
|
||||
const lastTimestamp = messages.length > 0
|
||||
? messages[messages.length - 1].createTime
|
||||
: undefined
|
||||
|
||||
this.sendJson(res, {
|
||||
...chatLabData,
|
||||
sync: {
|
||||
hasMore,
|
||||
nextSince: hasMore && lastTimestamp ? lastTimestamp : undefined,
|
||||
nextOffset: hasMore ? offset + messages.length : undefined,
|
||||
watermark: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[HttpService] handlePullMessages error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理联系人查询
|
||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||
@@ -721,6 +930,313 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
|
||||
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
|
||||
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
|
||||
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
|
||||
|
||||
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
|
||||
return
|
||||
}
|
||||
|
||||
let timeline = result.timeline || []
|
||||
if (resolveMedia && timeline.length > 0) {
|
||||
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: timeline.length,
|
||||
timeline
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
usernames: result.usernames || []
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const fast = this.parseBooleanParam(url, ['fast'], false)
|
||||
const result = fast
|
||||
? await snsService.getExportStatsFast()
|
||||
: await snsService.getExportStats()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const mediaUrl = (url.searchParams.get('url') || '').trim()
|
||||
if (!mediaUrl) {
|
||||
this.sendError(res, 400, 'Missing required parameter: url')
|
||||
return
|
||||
}
|
||||
|
||||
const key = this.toSnsMediaKey(url.searchParams.get('key'))
|
||||
const result = await snsService.downloadImage(mediaUrl, key)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', result.data.length)
|
||||
res.writeHead(200)
|
||||
res.end(result.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.cachePath && fs.existsSync(result.cachePath)) {
|
||||
try {
|
||||
const stat = fs.statSync(result.cachePath)
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', stat.size)
|
||||
res.writeHead(200)
|
||||
|
||||
const stream = fs.createReadStream(result.cachePath)
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
this.sendError(res, 500, 'Failed to read proxied sns media')
|
||||
} else {
|
||||
try { res.destroy() } catch {}
|
||||
}
|
||||
})
|
||||
stream.pipe(res)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Failed to stream sns media cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
}
|
||||
|
||||
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
|
||||
if (!outputDir) {
|
||||
this.sendError(res, 400, 'Missing required field: outputDir')
|
||||
return
|
||||
}
|
||||
|
||||
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||
return
|
||||
}
|
||||
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
|
||||
const options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
exportImages?: boolean
|
||||
exportLivePhotos?: boolean
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
} = {
|
||||
outputDir,
|
||||
format: format as 'json' | 'html' | 'arkmejson',
|
||||
usernames,
|
||||
keyword,
|
||||
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||
}
|
||||
|
||||
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
|
||||
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
|
||||
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
|
||||
if (startTimeRaw > 0) options.startTime = startTimeRaw
|
||||
if (endTimeRaw > 0) options.endTime = endTimeRaw
|
||||
|
||||
const result = await snsService.exportTimeline(options)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.checkSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.installSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.uninstallSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
|
||||
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
|
||||
if (!postId) {
|
||||
this.sendError(res, 400, 'Missing required path parameter: postId')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await snsService.deleteSnsPost(postId)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to delete sns post')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private toSnsMediaKey(value: unknown): string | number | undefined {
|
||||
if (value == null) return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
const text = String(value).trim()
|
||||
if (!text) return undefined
|
||||
if (/^-?\d+$/.test(text)) return Number(text)
|
||||
return text
|
||||
}
|
||||
|
||||
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
|
||||
const target = String(rawUrl || '').trim()
|
||||
if (!target) return undefined
|
||||
const params = new URLSearchParams({ url: target })
|
||||
if (key !== undefined) params.set('key', String(key))
|
||||
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
|
||||
}
|
||||
|
||||
private async resolveSnsMediaUrl(
|
||||
rawUrl: string,
|
||||
key: string | number | undefined,
|
||||
inline: boolean
|
||||
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
|
||||
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
|
||||
if (!proxyUrl) return {}
|
||||
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
|
||||
try {
|
||||
const resolved = await snsService.proxyImage(rawUrl, key)
|
||||
if (resolved.success && resolved.dataUrl) {
|
||||
return { resolvedUrl: resolved.dataUrl, proxyUrl }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
|
||||
}
|
||||
|
||||
return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
}
|
||||
|
||||
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
|
||||
return Promise.all(
|
||||
(posts || []).map(async (post) => {
|
||||
const mediaList = Array.isArray(post?.media) ? post.media : []
|
||||
if (mediaList.length === 0) return post
|
||||
|
||||
const nextMedia = await Promise.all(
|
||||
mediaList.map(async (media: any) => {
|
||||
const rawUrl = typeof media?.url === 'string' ? media.url : ''
|
||||
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
|
||||
const mediaKey = this.toSnsMediaKey(media?.key)
|
||||
|
||||
const [urlResolved, thumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
|
||||
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
|
||||
])
|
||||
|
||||
const nextItem: any = {
|
||||
...media,
|
||||
rawUrl,
|
||||
rawThumb,
|
||||
resolvedUrl: urlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: thumbResolved.resolvedUrl,
|
||||
proxyUrl: urlResolved.proxyUrl,
|
||||
proxyThumbUrl: thumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextItem.url = urlResolved.resolvedUrl || rawUrl
|
||||
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
|
||||
}
|
||||
|
||||
if (media?.livePhoto && typeof media.livePhoto === 'object') {
|
||||
const livePhoto = media.livePhoto
|
||||
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
|
||||
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
|
||||
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
|
||||
|
||||
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
|
||||
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
|
||||
])
|
||||
|
||||
const nextLive: any = {
|
||||
...livePhoto,
|
||||
rawUrl: rawLiveUrl,
|
||||
rawThumb: rawLiveThumb,
|
||||
resolvedUrl: liveUrlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
|
||||
proxyUrl: liveUrlResolved.proxyUrl,
|
||||
proxyThumbUrl: liveThumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
|
||||
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
|
||||
}
|
||||
|
||||
nextItem.livePhoto = nextLive
|
||||
}
|
||||
|
||||
return nextItem
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
...post,
|
||||
media: nextMedia
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
@@ -764,6 +1280,30 @@ class HttpService {
|
||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||
this.ensureDir(sessionDir)
|
||||
|
||||
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||
if (options.exportImages) {
|
||||
const imageMd5Set = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.localType !== 3) continue
|
||||
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
imageMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (imageMd5Set.size > 0) {
|
||||
try {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||
if (exported) {
|
||||
@@ -786,27 +1326,54 @@ class HttpService {
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
force: true
|
||||
createTime: msg.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
let imagePath = result.localPath
|
||||
|
||||
let imagePath = result.success ? result.localPath : undefined
|
||||
if (!imagePath) {
|
||||
try {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imagePath = cached.localPath
|
||||
}
|
||||
} catch {
|
||||
// ignore resolve failures
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (base64Match) {
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
if (!base64Match) return null
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
} else if (fs.existsSync(imagePath)) {
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath)
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
@@ -895,7 +1462,7 @@ class HttpService {
|
||||
parsedContent: msg.parsedContent,
|
||||
mediaType: media?.kind,
|
||||
mediaFileName: media?.fileName,
|
||||
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||
mediaLocalPath: media?.fullPath
|
||||
}
|
||||
}
|
||||
@@ -1165,7 +1732,7 @@ class HttpService {
|
||||
type: this.mapMessageType(msg.localType, msg),
|
||||
content: this.getMessageContent(msg),
|
||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1382,6 +1949,11 @@ class HttpService {
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
|
||||
res.setHeader('Allow', allow)
|
||||
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
|
||||
@@ -4,38 +4,63 @@ type PreloadImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
private queue: PreloadTask[] = []
|
||||
private pending = new Set<string>()
|
||||
private active = 0
|
||||
private readonly maxConcurrent = 2
|
||||
private activeCache = 0
|
||||
private activeDecrypt = 0
|
||||
private readonly maxCacheConcurrent = 8
|
||||
private readonly maxDecryptConcurrent = 2
|
||||
private readonly maxQueueSize = 320
|
||||
|
||||
enqueue(payloads: PreloadImagePayload[]): void {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) continue
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
||||
const task = this.queue.shift()
|
||||
while (this.queue.length > 0) {
|
||||
const taskIndex = this.queue.findIndex((task) => (
|
||||
task.allowDecrypt
|
||||
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||
: this.activeCache < this.maxCacheConcurrent
|
||||
))
|
||||
if (taskIndex < 0) return
|
||||
|
||||
const task = this.queue.splice(taskIndex, 1)[0]
|
||||
if (!task) return
|
||||
this.active += 1
|
||||
|
||||
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||
else this.activeCache += 1
|
||||
|
||||
void this.handleTask(task).finally(() => {
|
||||
this.active -= 1
|
||||
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
|
||||
else this.activeCache = Math.max(0, this.activeCache - 1)
|
||||
this.pending.delete(task.key)
|
||||
this.processQueue()
|
||||
})
|
||||
@@ -49,13 +74,25 @@ export class ImagePreloadService {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
|
||||
1309
electron/services/insightService.ts
Normal file
@@ -9,7 +9,7 @@ import crypto from 'crypto'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
@@ -684,10 +694,7 @@ export class KeyService {
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
// --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ---
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
@@ -807,7 +814,7 @@ export class KeyService {
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
@@ -819,7 +826,7 @@ export class KeyService {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { execFile, exec, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -10,28 +11,38 @@ const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
|
||||
export class KeyServiceLinux {
|
||||
private sudo: any
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.sudo = require('sudo-prompt');
|
||||
this.sudo = require('@vscode/sudo-prompt');
|
||||
} catch (e) {
|
||||
console.error('Failed to load sudo-prompt', e);
|
||||
console.error('Failed to load @vscode/sudo-prompt', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
@@ -88,7 +99,12 @@ export class KeyServiceLinux {
|
||||
'xwechat',
|
||||
'/opt/wechat/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/opt/apps/com.tencent.wechat/files/wechat'
|
||||
'/usr/local/bin/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/opt/apps/com.tencent.wechat/files/wechat',
|
||||
'/usr/bin/wechat-bin',
|
||||
'/usr/local/bin/wechat-bin',
|
||||
'com.tencent.wechat'
|
||||
]
|
||||
|
||||
for (const binName of wechatBins) {
|
||||
@@ -142,7 +158,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
@@ -228,7 +244,14 @@ export class KeyServiceLinux {
|
||||
if (account && account.keys && account.keys.length > 0) {
|
||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||
const keyObj = account.keys[0]
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||
const aesKey = String(keyObj.aesKey || '')
|
||||
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
|
||||
if (verified === true) {
|
||||
onProgress?.('缓存密钥校验成功,已确认可用')
|
||||
} else if (verified === false) {
|
||||
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
|
||||
}
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
|
||||
}
|
||||
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||
} catch (err: any) {
|
||||
@@ -236,6 +259,35 @@ export class KeyServiceLinux {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
|
||||
const normalizedPath = String(accountPath || '').trim()
|
||||
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
|
||||
try {
|
||||
const template = await this._findTemplateData(normalizedPath, 32)
|
||||
if (!template.ciphertext) return null
|
||||
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKeyByMemoryScan(
|
||||
accountPath: string,
|
||||
onProgress?: (msg: string) => void
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { join, basename, dirname } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export class KeyServiceMac {
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
@@ -373,31 +403,81 @@ export class KeyServiceMac {
|
||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||
const baseDir = dirname(primaryBinaryPath)
|
||||
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||
const unique: string[] = []
|
||||
for (const name of names) {
|
||||
const full = join(baseDir, name)
|
||||
if (!existsSync(full)) continue
|
||||
if (!unique.includes(full)) unique.push(full)
|
||||
}
|
||||
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||
unique.unshift(primaryBinaryPath)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const mode = statSync(p).mode
|
||||
if ((mode & 0o111) !== 0) continue
|
||||
chmodSync(p, mode | 0o111)
|
||||
} catch {
|
||||
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||
const existing = paths.filter(p => existsSync(p))
|
||||
if (existing.length === 0) return
|
||||
|
||||
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||
const scriptLines = [
|
||||
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'with timeout of timeoutSec seconds',
|
||||
'do shell script chmodCmd with administrator privileges',
|
||||
'end timeout'
|
||||
]
|
||||
|
||||
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
timeout: timeoutMs + 10_000
|
||||
})
|
||||
}
|
||||
|
||||
private async getDbKeyByHelperElevated(
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||
: ''
|
||||
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script cmd with administrator privileges',
|
||||
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
|
||||
'end timeout',
|
||||
'return "WF_OK::" & outText',
|
||||
'on error errMsg number errNum partial result pr',
|
||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||
|
||||
let stdout = ''
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
@@ -473,7 +553,19 @@ export class KeyServiceMac {
|
||||
if (code === 'HOOK_TARGET_ONLY') {
|
||||
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||
}
|
||||
if (code === 'SCAN_FAILED') return '内存扫描失败'
|
||||
if (code === 'SCAN_FAILED') {
|
||||
const normalizedDetail = (detail || '').trim()
|
||||
if (!normalizedDetail) {
|
||||
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||
}
|
||||
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
||||
}
|
||||
if (normalizedDetail.includes('No suitable module found')) {
|
||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
||||
}
|
||||
return `内存扫描失败:${normalizedDetail}`
|
||||
}
|
||||
return '未知错误'
|
||||
}
|
||||
|
||||
@@ -553,7 +645,7 @@ export class KeyServiceMac {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,7 +660,7 @@ export class KeyServiceMac {
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
||||
}
|
||||
@@ -721,10 +813,12 @@ export class KeyServiceMac {
|
||||
try {
|
||||
const helperPath = this.getImageScanHelperPath()
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
|
||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||
if (!this._needsElevation) {
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||
if (direct.key) return direct.key
|
||||
if (direct.permissionError) {
|
||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||
@@ -735,7 +829,12 @@ export class KeyServiceMac {
|
||||
|
||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||
if (this._needsElevation) {
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
||||
try {
|
||||
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||
} catch (e: any) {
|
||||
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||
}
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||
if (elevated.key) return elevated.key
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -838,12 +937,19 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private _spawnScanHelper(
|
||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
||||
helperPath: string,
|
||||
pid: number,
|
||||
ciphertextHex: string,
|
||||
elevated: boolean,
|
||||
artifactPaths: string[] = []
|
||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
if (elevated) {
|
||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||
: ''
|
||||
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} else {
|
||||
@@ -935,10 +1041,17 @@ export class KeyServiceMac {
|
||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (!normalized) return null
|
||||
|
||||
// 旧路径:xwechat_files
|
||||
const marker = '/xwechat_files'
|
||||
const markerIdx = normalized.indexOf(marker)
|
||||
if (markerIdx < 0) return null
|
||||
return normalized.slice(0, markerIdx + marker.length)
|
||||
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
|
||||
|
||||
// 新路径(微信 4.0.5+):Application Support/com.tencent.xinWeChat/2.0b4.0.9
|
||||
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
|
||||
if (newMarkerMatch) return newMarkerMatch[1]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||
@@ -1096,6 +1209,16 @@ export class KeyServiceMac {
|
||||
candidates.add(`${base}/app_data/net/kvcomm`)
|
||||
}
|
||||
|
||||
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
|
||||
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
|
||||
if (newMarkerMatch) {
|
||||
const versionBase = newMarkerMatch[1]
|
||||
candidates.add(`${versionBase}/net/kvcomm`)
|
||||
// 上级目录也尝试
|
||||
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
|
||||
candidates.add(`${parentBase}/net/kvcomm`)
|
||||
}
|
||||
|
||||
let cursor = accountPath
|
||||
for (let i = 0; i < 6; i++) {
|
||||
candidates.add(join(cursor, 'net', 'kvcomm'))
|
||||
|
||||
174
electron/services/linuxNotificationService.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
avatarUrl?: string;
|
||||
expireTimeout?: number;
|
||||
}
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
|
||||
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLinuxNotification(
|
||||
data: LinuxNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
notificationCallbacks.push(callback);
|
||||
}
|
||||
|
||||
export function removeNotificationCallback(
|
||||
callback: NotificationCallback,
|
||||
): void {
|
||||
const index = notificationCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
notificationCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLinuxNotificationService(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
} catch {}
|
||||
clearNotificationState(id);
|
||||
}
|
||||
|
||||
// 清理头像文件缓存
|
||||
try {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { ConfigService } from './config'
|
||||
import { chatService, type ChatSession, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { httpService } from './httpService'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
interface SessionBaseline {
|
||||
lastTimestamp: number
|
||||
@@ -11,15 +15,19 @@ interface SessionBaseline {
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
groupName?: string
|
||||
content: string | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'messagePushFilterMode',
|
||||
'messagePushFilterList',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -30,6 +38,8 @@ class MessagePushService {
|
||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||
private readonly recentMessageKeys = new Map<string, number>()
|
||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||
private readonly pushAvatarCacheDir: string
|
||||
private readonly pushAvatarDataCache = new Map<string, string>()
|
||||
private readonly debounceMs = 350
|
||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||
@@ -38,9 +48,11 @@ class MessagePushService {
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
private messageTableScanRequested = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -49,6 +61,13 @@ class MessagePushService {
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.processing = false
|
||||
this.rerunRequested = false
|
||||
this.resetRuntimeState()
|
||||
}
|
||||
|
||||
handleDbMonitorChange(type: string, json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isPushEnabled()) return
|
||||
@@ -60,12 +79,15 @@ class MessagePushService {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
const tableName = String(payload?.table || '').trim()
|
||||
if (this.isSessionTableChange(tableName)) {
|
||||
this.scheduleSync()
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleSync()
|
||||
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
@@ -91,6 +113,7 @@ class MessagePushService {
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
this.messageTableScanRequested = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
@@ -121,7 +144,11 @@ class MessagePushService {
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||
if (options.scanMessageBackedSessions) {
|
||||
this.messageTableScanRequested = true
|
||||
}
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
@@ -141,6 +168,8 @@ class MessagePushService {
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||
this.messageTableScanRequested = false
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
@@ -163,27 +192,47 @@ class MessagePushService {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
this.setBaseline(sessions)
|
||||
|
||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||
const candidates = sessions.filter((session) => {
|
||||
const previous = previousBaseline.get(session.username)
|
||||
if (this.shouldInspectSession(previous, session)) {
|
||||
return true
|
||||
}
|
||||
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||
})
|
||||
for (const session of candidates) {
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
await this.pushSessionMessages(
|
||||
session,
|
||||
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync()
|
||||
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setBaseline(sessions: ChatSession[]): void {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
const nextBaseline = new Map<string, SessionBaseline>()
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
this.sessionBaseline.clear()
|
||||
for (const session of sessions) {
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
const username = String(session.username || '').trim()
|
||||
if (!username) continue
|
||||
const previous = previousBaseline.get(username)
|
||||
const sessionTimestamp = Number(session.lastTimestamp || 0)
|
||||
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
|
||||
nextBaseline.set(username, {
|
||||
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
|
||||
unreadCount: Number(session.unreadCount || 0)
|
||||
})
|
||||
}
|
||||
for (const [username, baseline] of nextBaseline.entries()) {
|
||||
this.sessionBaseline.set(username, baseline)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
@@ -204,16 +253,30 @@ class MessagePushService {
|
||||
return unreadCount > 0 && lastTimestamp > 0
|
||||
}
|
||||
|
||||
if (lastTimestamp <= previous.lastTimestamp) {
|
||||
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
|
||||
}
|
||||
|
||||
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
const summary = String(session.summary || '').trim()
|
||||
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
if (sessionType === 'private') {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
|
||||
}
|
||||
|
||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
@@ -224,7 +287,7 @@ class MessagePushService {
|
||||
if (!messageKey) continue
|
||||
if (message.isSend === 1) continue
|
||||
|
||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -234,9 +297,11 @@ class MessagePushService {
|
||||
|
||||
const payload = await this.buildPayload(session, message)
|
||||
if (!payload) continue
|
||||
if (!this.shouldPushPayload(payload)) continue
|
||||
|
||||
httpService.broadcastMessagePush(payload)
|
||||
this.rememberMessageKey(messageKey)
|
||||
this.bumpSessionBaseline(session.username, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,38 +311,166 @@ class MessagePushService {
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
const createTime = Number(message.createTime || 0)
|
||||
|
||||
if (isGroup) {
|
||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
avatarUrl,
|
||||
groupName,
|
||||
sourceName,
|
||||
content
|
||||
content,
|
||||
timestamp: createTime
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
content
|
||||
content,
|
||||
timestamp: createTime
|
||||
}
|
||||
}
|
||||
|
||||
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
|
||||
const normalized = String(avatarUrl || '').trim()
|
||||
if (!normalized) return undefined
|
||||
if (!normalized.startsWith('data:image/')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const cached = this.pushAvatarDataCache.get(normalized)
|
||||
if (cached) return cached
|
||||
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
|
||||
if (!match) return undefined
|
||||
|
||||
try {
|
||||
const mimeType = match[1].toLowerCase()
|
||||
const base64Data = match[2]
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||
if (!imageBuffer.length) return undefined
|
||||
|
||||
const ext = this.getImageExtFromMime(mimeType)
|
||||
const hash = createHash('sha1').update(normalized).digest('hex')
|
||||
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
|
||||
|
||||
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
await fs.writeFile(filePath, imageBuffer)
|
||||
}
|
||||
|
||||
const fileUrl = pathToFileURL(filePath).toString()
|
||||
this.pushAvatarDataCache.set(normalized, fileUrl)
|
||||
return fileUrl
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private getImageExtFromMime(mimeType: string): string {
|
||||
if (mimeType === 'image/png') return 'png'
|
||||
if (mimeType === 'image/gif') return 'gif'
|
||||
if (mimeType === 'image/webp') return 'webp'
|
||||
return 'jpg'
|
||||
}
|
||||
|
||||
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
return 'group'
|
||||
}
|
||||
if (sessionId.startsWith('gh_') || session.type === 'official') {
|
||||
return 'official'
|
||||
}
|
||||
if (session.type === 'friend') {
|
||||
return 'private'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
private shouldPushPayload(payload: MessagePushPayload): boolean {
|
||||
const sessionId = String(payload.sessionId || '').trim()
|
||||
const filterMode = this.getMessagePushFilterMode()
|
||||
if (filterMode === 'all') {
|
||||
return true
|
||||
}
|
||||
|
||||
const filterList = this.getMessagePushFilterList()
|
||||
const listed = filterList.has(sessionId)
|
||||
if (filterMode === 'whitelist') {
|
||||
return listed
|
||||
}
|
||||
return !listed
|
||||
}
|
||||
|
||||
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
|
||||
const value = this.configService.get('messagePushFilterMode')
|
||||
if (value === 'whitelist' || value === 'blacklist') return value
|
||||
return 'all'
|
||||
}
|
||||
|
||||
private getMessagePushFilterList(): Set<string> {
|
||||
const value = this.configService.get('messagePushFilterList')
|
||||
if (!Array.isArray(value)) return new Set()
|
||||
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
|
||||
}
|
||||
|
||||
private isSessionTableChange(tableName: string): boolean {
|
||||
return String(tableName || '').trim().toLowerCase() === 'session'
|
||||
}
|
||||
|
||||
private isMessageTableChange(tableName: string): boolean {
|
||||
const normalized = String(tableName || '').trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return normalized === 'message' ||
|
||||
normalized === 'msg' ||
|
||||
normalized.startsWith('message_') ||
|
||||
normalized.startsWith('msg_') ||
|
||||
normalized.includes('message')
|
||||
}
|
||||
|
||||
private bumpSessionBaseline(sessionId: string, message: Message): void {
|
||||
const key = String(sessionId || '').trim()
|
||||
if (!key) return
|
||||
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (!Number.isFinite(createTime) || createTime <= 0) return
|
||||
|
||||
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
|
||||
if (createTime > current.lastTimestamp) {
|
||||
this.sessionBaseline.set(key, {
|
||||
...current,
|
||||
lastTimestamp: createTime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageDisplayContent(message: Message): string | null {
|
||||
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||
if (!value) return value
|
||||
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||
}
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return message.rawContent || null
|
||||
return cleanOfficialPrefix(message.rawContent || null)
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -287,13 +480,13 @@ class MessagePushService {
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return message.cardNickname || '[名片]'
|
||||
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||
default:
|
||||
return message.parsedContent || message.rawContent || null
|
||||
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
|
||||
function shouldEnableNative(): boolean {
|
||||
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||
}
|
||||
|
||||
function expandAsarCandidates(filePath: string): string[] {
|
||||
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||
return [filePath]
|
||||
}
|
||||
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||
}
|
||||
|
||||
function getPlatformDir(): string {
|
||||
if (process.platform === 'win32') return 'win32'
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return process.platform
|
||||
}
|
||||
|
||||
function getArchDir(): string {
|
||||
if (process.arch === 'x64') return 'x64'
|
||||
if (process.arch === 'arm64') return 'arm64'
|
||||
return process.arch
|
||||
}
|
||||
|
||||
function getAddonCandidates(): string[] {
|
||||
const platformDir = getPlatformDir()
|
||||
const archDir = getArchDir()
|
||||
const cwd = process.cwd()
|
||||
const fileNames = [
|
||||
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||
]
|
||||
const roots = [
|
||||
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||
]
|
||||
: [])
|
||||
]
|
||||
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
function loadAddon(): NativeAddon | null {
|
||||
if (!shouldEnableNative()) return null
|
||||
if (cachedAddon !== undefined) return cachedAddon
|
||||
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addon = require(candidate) as NativeAddon
|
||||
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||
cachedAddon = addon
|
||||
return addon
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeAddonLocation(): string | null {
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
try {
|
||||
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import crypto from 'crypto'
|
||||
@@ -173,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||||
// BMP
|
||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||
|
||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||||
// ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4
|
||||
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
|
||||
if (
|
||||
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||
) return 'image/heic'
|
||||
return 'video/mp4'
|
||||
}
|
||||
|
||||
// Fallback logic for video
|
||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||
@@ -537,6 +547,32 @@ class SnsService {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
throw new Error(result.error || '获取朋友圈发布者失败')
|
||||
}
|
||||
|
||||
const rows = result.timeline
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (username) uniqueUsers.add(username)
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return Array.from(uniqueUsers)
|
||||
}
|
||||
|
||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
@@ -775,14 +811,25 @@ class SnsService {
|
||||
}
|
||||
|
||||
private getSnsCacheDir(): string {
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const snsCacheDir = join(baseDir, 'sns_cache')
|
||||
if (!existsSync(snsCacheDir)) {
|
||||
mkdirSync(snsCacheDir, { recursive: true })
|
||||
}
|
||||
return snsCacheDir
|
||||
}
|
||||
|
||||
private getEmojiCacheDir(): string {
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const emojiDir = join(baseDir, 'Emojis')
|
||||
if (!existsSync(emojiDir)) {
|
||||
mkdirSync(emojiDir, { recursive: true })
|
||||
}
|
||||
return emojiDir
|
||||
}
|
||||
|
||||
private getCacheFilePath(url: string): string {
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||
@@ -794,7 +841,22 @@ class SnsService {
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
|
||||
if (directUsernames.length > 0) {
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
|
||||
try {
|
||||
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
|
||||
if (timelineUsers.length > 0) {
|
||||
return { success: true, usernames: timelineUsers }
|
||||
}
|
||||
} catch {
|
||||
// 忽略回退错误,保持与原行为一致返回空数组
|
||||
}
|
||||
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
@@ -1021,14 +1083,14 @@ class SnsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 补全 DLL 返回的评论中缺失的 refNickname
|
||||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 补全数据服务返回的评论中缺失的 refNickname
|
||||
*数据服务返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||
*/
|
||||
private fixCommentRefs(comments: any[]): any[] {
|
||||
if (!comments || comments.length === 0) return []
|
||||
|
||||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||||
//数据服务现在返回完整的评论数据(含 emojis、refNickname)
|
||||
// 此处做最终的格式化和兜底补全
|
||||
const idToNickname = new Map<string, string>()
|
||||
comments.forEach((c, idx) => {
|
||||
@@ -1099,14 +1161,14 @@ class SnsService {
|
||||
} : undefined
|
||||
}))
|
||||
|
||||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
//数据服务已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
const dllComments: any[] = post.comments || []
|
||||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||
|
||||
let finalComments: any[]
|
||||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||
// DLL 数据完整,直接使用
|
||||
//数据服务数据完整,直接使用
|
||||
finalComments = this.fixCommentRefs(dllComments)
|
||||
} else if (rawXml) {
|
||||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||
@@ -1178,7 +1240,19 @@ class SnsService {
|
||||
const cacheKey = `${url}|${key ?? ''}`
|
||||
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||
if (base64Part) {
|
||||
try {
|
||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||
return { success: true, dataUrl: cachedDataUrl }
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to refetch
|
||||
}
|
||||
}
|
||||
this.imageCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const result = await this.fetchAndDecryptImage(url, key)
|
||||
@@ -1191,6 +1265,9 @@ class SnsService {
|
||||
}
|
||||
|
||||
if (result.data && result.contentType) {
|
||||
if (!detectImageMime(result.data, '').startsWith('image/')) {
|
||||
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||
}
|
||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
return { success: true, dataUrl }
|
||||
@@ -1199,7 +1276,7 @@ class SnsService {
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||
return this.fetchAndDecryptImage(url, key)
|
||||
}
|
||||
|
||||
@@ -1791,7 +1868,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const isVideo = isVideoUrl(url)
|
||||
const cachePath = this.getCacheFilePath(url)
|
||||
|
||||
// 1. 尝试从磁盘缓存读取
|
||||
// 1. 优先尝试从当前缓存目录读取
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||
@@ -1800,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const data = await readFile(cachePath)
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
if (!detectImageMime(data, '').startsWith('image/')) {
|
||||
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
|
||||
try { unlinkSync(cachePath) } catch { }
|
||||
} else {
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||
}
|
||||
@@ -1953,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||
|
||||
let decoded = raw
|
||||
const rawMagicMime = detectImageMime(raw, '')
|
||||
|
||||
// 图片逻辑
|
||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||
@@ -1970,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
decrypted[i] = raw[i] ^ keystream[i]
|
||||
}
|
||||
|
||||
decoded = decrypted
|
||||
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||
if (decryptedMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
} else if (!rawMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsService] TS Decrypt Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const decodedMagicMime = detectImageMime(decoded, '')
|
||||
if (!decodedMagicMime.startsWith('image/')) {
|
||||
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||
return
|
||||
}
|
||||
|
||||
// 写入磁盘缓存
|
||||
try {
|
||||
await writeFile(cachePath, decoded)
|
||||
@@ -2010,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
||||
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
|
||||
if (
|
||||
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||
) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2252,9 +2355,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
|
||||
const fs = require('fs')
|
||||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||||
const emojiDir = this.getEmojiCacheDir()
|
||||
|
||||
// 检查本地缓存
|
||||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||
|
||||
367
electron/services/social/weiboService.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import https from 'https'
|
||||
import { createHash } from 'crypto'
|
||||
import { URL } from 'url'
|
||||
|
||||
const WEIBO_TIMEOUT_MS = 10_000
|
||||
const WEIBO_MAX_POSTS = 5
|
||||
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
const WEIBO_USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
||||
const WEIBO_MOBILE_USER_AGENT =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
|
||||
interface BrowserCookieEntry {
|
||||
domain?: string
|
||||
name?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
interface WeiboUserInfo {
|
||||
id?: number | string
|
||||
screen_name?: string
|
||||
}
|
||||
|
||||
interface WeiboWaterFallItem {
|
||||
id?: number | string
|
||||
idstr?: string
|
||||
mblogid?: string
|
||||
created_at?: string
|
||||
text_raw?: string
|
||||
isLongText?: boolean
|
||||
user?: WeiboUserInfo
|
||||
retweeted_status?: WeiboWaterFallItem
|
||||
}
|
||||
|
||||
interface WeiboWaterFallResponse {
|
||||
ok?: number
|
||||
data?: {
|
||||
list?: WeiboWaterFallItem[]
|
||||
next_cursor?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface WeiboStatusShowResponse {
|
||||
id?: number | string
|
||||
idstr?: string
|
||||
mblogid?: string
|
||||
created_at?: string
|
||||
text_raw?: string
|
||||
user?: WeiboUserInfo
|
||||
retweeted_status?: WeiboWaterFallItem
|
||||
}
|
||||
|
||||
interface MWeiboCard {
|
||||
mblog?: WeiboWaterFallItem
|
||||
card_group?: MWeiboCard[]
|
||||
}
|
||||
|
||||
interface MWeiboContainerResponse {
|
||||
ok?: number
|
||||
data?: {
|
||||
cards?: MWeiboCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface WeiboRecentPost {
|
||||
id: string
|
||||
createdAt: string
|
||||
url: string
|
||||
text: string
|
||||
screenName?: string
|
||||
}
|
||||
|
||||
interface CachedRecentPosts {
|
||||
expiresAt: number
|
||||
posts: WeiboRecentPost[]
|
||||
}
|
||||
|
||||
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(url)
|
||||
} catch {
|
||||
reject(new Error(`无效的微博请求地址:${url}`))
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Referer: options.referer || 'https://weibo.com',
|
||||
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
if (options.cookie) {
|
||||
headers.Cookie = options.cookie
|
||||
}
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || 443,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers
|
||||
},
|
||||
(res) => {
|
||||
let raw = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', (chunk) => {
|
||||
raw += chunk
|
||||
})
|
||||
res.on('end', () => {
|
||||
const statusCode = res.statusCode || 0
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw) as T)
|
||||
} catch {
|
||||
reject(new Error('微博接口返回了非 JSON 响应'))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
|
||||
req.destroy()
|
||||
reject(new Error('微博请求超时'))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
|
||||
const picked = new Map<string, string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = String(entry?.name || '').trim()
|
||||
const value = String(entry?.value || '').trim()
|
||||
const domain = String(entry?.domain || '').trim().toLowerCase()
|
||||
|
||||
if (!name || !value) continue
|
||||
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
|
||||
|
||||
picked.set(name, value)
|
||||
}
|
||||
|
||||
return Array.from(picked.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function normalizeWeiboCookieInput(rawInput: string): string {
|
||||
const trimmed = String(rawInput || '').trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
|
||||
if (normalized) return normalized
|
||||
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.replace(/^Cookie:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
function normalizeWeiboUid(input: string): string {
|
||||
const trimmed = String(input || '').trim()
|
||||
const directMatch = trimmed.match(/^\d{5,}$/)
|
||||
if (directMatch) return directMatch[0]
|
||||
|
||||
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
|
||||
if (linkMatch) return linkMatch[1]
|
||||
|
||||
throw new Error('请输入有效的微博 UID(纯数字)')
|
||||
}
|
||||
|
||||
function sanitizeWeiboText(text: string): string {
|
||||
return String(text || '')
|
||||
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
|
||||
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
|
||||
.replace(/ +/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
|
||||
const baseText = sanitizeWeiboText(item.text_raw || '')
|
||||
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
|
||||
if (!retweetText) return baseText
|
||||
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
|
||||
return `${baseText}\n\n转发内容:${retweetText}`
|
||||
}
|
||||
|
||||
function buildCacheKey(uid: string, count: number, cookie: string): string {
|
||||
const cookieHash = createHash('sha1').update(cookie).digest('hex')
|
||||
return `${uid}:${count}:${cookieHash}`
|
||||
}
|
||||
|
||||
class WeiboService {
|
||||
private recentPostsCache = new Map<string, CachedRecentPosts>()
|
||||
|
||||
clearCache(): void {
|
||||
this.recentPostsCache.clear()
|
||||
}
|
||||
|
||||
async validateUid(
|
||||
uidInput: string,
|
||||
cookieInput: string
|
||||
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
|
||||
try {
|
||||
const uid = normalizeWeiboUid(uidInput)
|
||||
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||
if (!cookie) {
|
||||
return { success: true, uid }
|
||||
}
|
||||
|
||||
const timeline = await this.fetchTimeline(uid, cookie)
|
||||
const firstItem = timeline.data?.list?.[0]
|
||||
if (!firstItem) {
|
||||
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
uid,
|
||||
screenName: firstItem.user?.screen_name
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message || '微博 UID 校验失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRecentPosts(
|
||||
uidInput: string,
|
||||
cookieInput: string,
|
||||
requestedCount: number
|
||||
): Promise<WeiboRecentPost[]> {
|
||||
const uid = normalizeWeiboUid(uidInput)
|
||||
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||
const hasCookie = Boolean(cookie)
|
||||
|
||||
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
|
||||
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
|
||||
const cached = this.recentPostsCache.get(cacheKey)
|
||||
const now = Date.now()
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.posts
|
||||
}
|
||||
|
||||
const rawItems = hasCookie
|
||||
? (await this.fetchTimeline(uid, cookie)).data?.list || []
|
||||
: await this.fetchMobileTimeline(uid)
|
||||
const posts: WeiboRecentPost[] = []
|
||||
|
||||
for (const item of rawItems) {
|
||||
if (posts.length >= count) break
|
||||
|
||||
const id = String(item.idstr || item.id || '').trim()
|
||||
if (!id) continue
|
||||
|
||||
let text = mergeRetweetText(item)
|
||||
if (item.isLongText && hasCookie) {
|
||||
try {
|
||||
const detail = await this.fetchDetail(id, cookie)
|
||||
text = mergeRetweetText(detail)
|
||||
} catch {
|
||||
// 长文补抓失败时回退到列表摘要
|
||||
}
|
||||
}
|
||||
|
||||
text = sanitizeWeiboText(text)
|
||||
if (!text) continue
|
||||
|
||||
posts.push({
|
||||
id,
|
||||
createdAt: String(item.created_at || ''),
|
||||
url: `https://m.weibo.cn/detail/${id}`,
|
||||
text,
|
||||
screenName: item.user?.screen_name
|
||||
})
|
||||
}
|
||||
|
||||
this.recentPostsCache.set(cacheKey, {
|
||||
expiresAt: now + WEIBO_CACHE_TTL_MS,
|
||||
posts
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
|
||||
return requestJson<WeiboWaterFallResponse>(
|
||||
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
|
||||
{
|
||||
cookie,
|
||||
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
|
||||
}
|
||||
).then((response) => {
|
||||
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
|
||||
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
|
||||
const containerid = `107603${uid}`
|
||||
return requestJson<MWeiboContainerResponse>(
|
||||
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
|
||||
{
|
||||
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
|
||||
userAgent: WEIBO_MOBILE_USER_AGENT
|
||||
}
|
||||
).then((response) => {
|
||||
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
|
||||
throw new Error('微博时间线获取失败,请稍后重试')
|
||||
}
|
||||
|
||||
const rows: WeiboWaterFallItem[] = []
|
||||
for (const card of response.data.cards) {
|
||||
if (card?.mblog) rows.push(card.mblog)
|
||||
if (Array.isArray(card?.card_group)) {
|
||||
for (const subCard of card.card_group) {
|
||||
if (subCard?.mblog) rows.push(subCard.mblog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('该微博账号暂无可读取的近期公开内容')
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
|
||||
return requestJson<WeiboStatusShowResponse>(
|
||||
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
|
||||
{
|
||||
cookie,
|
||||
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
|
||||
}
|
||||
).then((response) => {
|
||||
if (!response || (!response.id && !response.idstr)) {
|
||||
throw new Error('微博详情获取失败')
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const weiboService = new WeiboService()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
@@ -22,6 +23,8 @@ interface VideoIndexEntry {
|
||||
thumbPath?: string
|
||||
}
|
||||
|
||||
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
@@ -249,19 +252,15 @@ class VideoService {
|
||||
}
|
||||
|
||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
if (!dbPath || !wxid) return
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||
void md5List
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||
try {
|
||||
if (!filePath || !existsSync(filePath)) return undefined
|
||||
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
@@ -355,7 +354,12 @@ class VideoService {
|
||||
return index
|
||||
}
|
||||
|
||||
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||
private getVideoInfoFromIndex(
|
||||
index: Map<string, VideoIndexEntry>,
|
||||
md5: string,
|
||||
includePoster = true,
|
||||
posterFormat: PosterFormat = 'dataUrl'
|
||||
): VideoInfo | null {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
@@ -379,8 +383,8 @@ class VideoService {
|
||||
}
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -388,7 +392,29 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||
private normalizeVideoLookupKey(value: string): string {
|
||||
let text = String(value || '').trim().toLowerCase()
|
||||
if (!text) return ''
|
||||
text = text.replace(/^.*[\\/]/, '')
|
||||
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||
text = text.replace(/_thumb$/, '')
|
||||
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||
if (direct) {
|
||||
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||
return `${direct[1].toLowerCase()}${suffix}`
|
||||
}
|
||||
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||
return String(fallback?.[1] || '').toLowerCase()
|
||||
}
|
||||
|
||||
private fallbackScanVideo(
|
||||
videoBaseDir: string,
|
||||
realVideoMd5: string,
|
||||
includePoster = true,
|
||||
posterFormat: PosterFormat = 'dataUrl'
|
||||
): VideoInfo | null {
|
||||
try {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
@@ -416,8 +442,8 @@ class VideoService {
|
||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -427,14 +453,21 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||
void posterFormat
|
||||
if (!includePoster) return info
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
|
||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||
const includePoster = options?.includePoster !== false
|
||||
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
@@ -446,7 +479,7 @@ class VideoService {
|
||||
}
|
||||
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
|
||||
|
||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||
if (cachedInfo) return cachedInfo
|
||||
@@ -455,7 +488,7 @@ class VideoService {
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async (): Promise<VideoInfo> => {
|
||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
@@ -465,21 +498,23 @@ class VideoService {
|
||||
}
|
||||
|
||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
|
||||
if (indexed) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return indexed
|
||||
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return withPoster
|
||||
}
|
||||
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
|
||||
if (fallback) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return fallback
|
||||
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return withPoster
|
||||
}
|
||||
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
|
||||
return miss
|
||||
})()
|
||||
|
||||
|
||||
@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖
|
||||
const existing = env['PATH'] || ''
|
||||
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
|
||||
@@ -25,9 +25,7 @@ export class WcdbService {
|
||||
private logEnabled = false
|
||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.initWorker()
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 初始化 Worker 线程
|
||||
@@ -80,7 +78,7 @@ export class WcdbService {
|
||||
// Worker 退出,需要 reject 所有 pending promises
|
||||
if (code !== 0) {
|
||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(errorMsg))
|
||||
}
|
||||
@@ -268,6 +266,37 @@ export class WcdbService {
|
||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||
}
|
||||
|
||||
async getMediaStream(options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
items?: Array<{
|
||||
sessionId: string
|
||||
sessionDisplayName?: string
|
||||
mediaType: 'image' | 'video'
|
||||
localId: number
|
||||
serverId?: string
|
||||
createTime: number
|
||||
localType: number
|
||||
senderUsername?: string
|
||||
isSend?: number | null
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
videoMd5?: string
|
||||
content?: string
|
||||
}>
|
||||
hasMore?: boolean
|
||||
nextOffset?: number
|
||||
error?: string
|
||||
}> {
|
||||
return this.callWorker('getMediaStream', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -417,6 +446,19 @@ export class WcdbService {
|
||||
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getMyFootprintStats(options: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
myWxid?: string
|
||||
privateSessionIds?: string[]
|
||||
groupSessionIds?: string[]
|
||||
mentionLimit?: number
|
||||
privateLimit?: number
|
||||
mentionMode?: 'text_at_me' | string
|
||||
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMyFootprintStats', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开消息游标
|
||||
*/
|
||||
@@ -467,7 +509,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表情包释义(严格 DLL 接口)
|
||||
* 获取表情包释义(严格数据服务接口)
|
||||
*/
|
||||
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||
@@ -561,6 +603,24 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsExportStats', { myWxid })
|
||||
}
|
||||
|
||||
async checkMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async installMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async uninstallMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
@@ -590,7 +650,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DLL 内部日志
|
||||
* 获取数据服务内部日志
|
||||
*/
|
||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||
return this.callWorker('getLogs')
|
||||
|
||||
20
electron/utils/pathUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { homedir } from 'os'
|
||||
|
||||
/**
|
||||
* Expand "~" prefix to current user's home directory.
|
||||
* Examples:
|
||||
* - "~" => "/Users/alex"
|
||||
* - "~/Library/..." => "/Users/alex/Library/..."
|
||||
*/
|
||||
export function expandHomePath(inputPath: string): string {
|
||||
const raw = String(inputPath || '').trim()
|
||||
if (!raw) return raw
|
||||
|
||||
if (raw === '~') return homedir()
|
||||
if (/^~[\\/]/.test(raw)) {
|
||||
return `${homedir()}${raw.slice(1)}`
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ if (parentPort) {
|
||||
case 'getMessagesByType':
|
||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMediaStream':
|
||||
result = await core.getMediaStream(payload.options)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -155,6 +158,9 @@ if (parentPort) {
|
||||
case 'getGroupStats':
|
||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getMyFootprintStats':
|
||||
result = await core.getMyFootprintStats(payload.options || {})
|
||||
break
|
||||
case 'openMessageCursor':
|
||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -230,6 +236,15 @@ if (parentPort) {
|
||||
case 'getSnsExportStats':
|
||||
result = await core.getSnsExportStats(payload.myWxid)
|
||||
break
|
||||
case 'checkMessageAntiRevokeTriggers':
|
||||
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installMessageAntiRevokeTriggers':
|
||||
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'uninstallMessageAntiRevokeTriggers':
|
||||
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
|
||||
@@ -1,224 +1,343 @@
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from '../services/config'
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
// Linux D-Bus通知服务
|
||||
const isLinux = process.platform === "linux";
|
||||
let linuxNotificationService:
|
||||
| typeof import("../services/linuxNotificationService")
|
||||
| null = null;
|
||||
|
||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||
|
||||
export function setNotificationNavigateHandler(
|
||||
callback: (sessionId: string) => void,
|
||||
) {
|
||||
onNotificationNavigate = callback;
|
||||
}
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null;
|
||||
let closeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (isLinux && linuxNotificationService) {
|
||||
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||
});
|
||||
linuxNotificationService = null;
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
const win = notificationWindow;
|
||||
notificationWindow = null;
|
||||
|
||||
try {
|
||||
win.destroy();
|
||||
} catch (error) {
|
||||
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||
const iconPath = isDev
|
||||
? join(__dirname, "../../public/icon.ico")
|
||||
: join(process.resourcesPath, "icon.ico");
|
||||
|
||||
console.log('[NotificationWindow] Creating window...')
|
||||
const width = 344
|
||||
const height = 114
|
||||
console.log("[NotificationWindow] Creating window...");
|
||||
const width = 344;
|
||||
const height = 114;
|
||||
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
}
|
||||
})
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
},
|
||||
});
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||
|
||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||
notificationWindow.loadURL(loadUrl)
|
||||
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||
notificationWindow.loadURL(loadUrl);
|
||||
|
||||
notificationWindow.on('closed', () => {
|
||||
notificationWindow = null
|
||||
})
|
||||
notificationWindow.on("closed", () => {
|
||||
notificationWindow = null;
|
||||
});
|
||||
|
||||
return notificationWindow
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance()
|
||||
const enabled = await config.get('notificationEnabled')
|
||||
if (enabled === false) return // 默认为 true
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance();
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||
const filterList = config.get('notificationFilterList') || []
|
||||
const sessionId = data.sessionId
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
||||
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||
|
||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId)
|
||||
if (filterMode === 'whitelist' && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (filterMode === 'blacklist' && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (!isSystemNotification && filterMode !== "all") {
|
||||
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||
return;
|
||||
}
|
||||
|
||||
let win = notificationWindow
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow()
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!win) return
|
||||
// Linux 使用 D-Bus 通知
|
||||
if (isLinux) {
|
||||
await showLinuxNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once('ready-to-show', () => {
|
||||
showAndSend(win!, data)
|
||||
})
|
||||
} else {
|
||||
showAndSend(win, data)
|
||||
}
|
||||
let win = notificationWindow;
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow();
|
||||
}
|
||||
|
||||
if (!win) return;
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once("ready-to-show", () => {
|
||||
showAndSend(win!, data);
|
||||
});
|
||||
} else {
|
||||
showAndSend(win, data);
|
||||
}
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null
|
||||
// 显示Linux通知
|
||||
async function showLinuxNotification(data: any) {
|
||||
if (!linuxNotificationService) {
|
||||
try {
|
||||
linuxNotificationService =
|
||||
await import("../services/linuxNotificationService");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to load Linux notification service:",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||
|
||||
const notificationData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
avatarUrl: data.avatarUrl,
|
||||
sessionId: data.sessionId,
|
||||
expireTimeout: 5000,
|
||||
};
|
||||
|
||||
showNotification(notificationData);
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null;
|
||||
|
||||
async function showAndSend(win: BrowserWindow, data: any) {
|
||||
lastNotificationData = data
|
||||
const config = ConfigService.getInstance()
|
||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||
lastNotificationData = data;
|
||||
const config = ConfigService.getInstance();
|
||||
const position = (await config.get("notificationPosition")) || "top-right";
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } =
|
||||
screen.getPrimaryDisplay().workAreaSize;
|
||||
const winWidth = position === "top-center" ? 280 : 344;
|
||||
const winHeight = 114;
|
||||
const padding = 20;
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
case 'top-left':
|
||||
x = padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-left':
|
||||
x = padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
switch (position) {
|
||||
case "top-center":
|
||||
x = (screenWidth - winWidth) / 2;
|
||||
y = padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.setSize(winWidth, winHeight); // 确保尺寸
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.showInactive(); // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||
|
||||
win.webContents.send("notification:show", { ...data, position });
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
// 注册通知处理
|
||||
export async function registerNotificationHandlers() {
|
||||
// Linux: 初始化D-Bus服务
|
||||
if (isLinux) {
|
||||
try {
|
||||
const linuxNotificationModule =
|
||||
await import("../services/linuxNotificationService");
|
||||
linuxNotificationService = linuxNotificationModule;
|
||||
|
||||
// 初始化服务
|
||||
await linuxNotificationModule.initLinuxNotificationService();
|
||||
|
||||
// 在Linux上注册通知点击回调
|
||||
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||
sessionId,
|
||||
);
|
||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||
if (onNotificationNavigate) {
|
||||
onNotificationNavigate(sessionId);
|
||||
} else {
|
||||
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||
console.warn(
|
||||
"[NotificationWindow] onNotificationNavigate not set yet",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification service initialized",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to initialize Linux notification service:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y))
|
||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||
ipcMain.handle("notification:show", (_, data) => {
|
||||
showNotification(data);
|
||||
});
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false)
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
ipcMain.handle("notification:close", () => {
|
||||
if (isLinux && linuxNotificationService) {
|
||||
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide();
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.send('notification:show', { ...data, position })
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on("notification:ready", (event) => {
|
||||
if (isLinux) {
|
||||
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||
return;
|
||||
}
|
||||
console.log("[NotificationWindow] Renderer ready, checking cached data");
|
||||
if (
|
||||
lastNotificationData &&
|
||||
notificationWindow &&
|
||||
!notificationWindow.isDestroyed()
|
||||
) {
|
||||
console.log("[NotificationWindow] Re-sending cached data");
|
||||
notificationWindow.webContents.send(
|
||||
"notification:show",
|
||||
lastNotificationData,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers() {
|
||||
ipcMain.handle('notification:show', (_, data) => {
|
||||
showNotification(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('notification:close', () => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide()
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on('notification:ready', (event) => {
|
||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
console.log('[NotificationWindow] Re-sending cached data')
|
||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds()
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||
}
|
||||
})
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||
if (isLinux) {
|
||||
// Linux 通知通过D-Bus自动调整大小
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds();
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||
}
|
||||
});
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
}
|
||||
|
||||
4114
package-lock.json
generated
97
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "2.1.0",
|
||||
"version": "4.3.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": {
|
||||
@@ -13,19 +13,19 @@
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.1",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
@@ -34,11 +34,11 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
@@ -52,15 +52,27 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^25.1.8",
|
||||
"sass": "^1.83.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"tar": ">=6.2.1",
|
||||
"minimatch": ">=3.1.2",
|
||||
"rollup": ">=4.0.0",
|
||||
"immutable": ">=4.0.0",
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
@@ -84,24 +96,47 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"appimage",
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux"
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -151,26 +186,14 @@
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
"node_modules/ffmpeg-static/**/*",
|
||||
"resources/wedecrypt/**/*.node"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
"tar": "^7.5.13",
|
||||
"immutable": "^5.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/icon.ico
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
30
resources/installer/linux/PKGBUILD
Normal file
@@ -0,0 +1,30 @@
|
||||
# Maintainer: H3CoF6 <h3cof6@gmail.com>
|
||||
pkgname=weflow
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="A local WeChat database decryption and analysis tool"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/hicccc77/weflow"
|
||||
license=('CC-BY-NC-SA-4.0')
|
||||
depends=('alsa-lib' 'gtk3' 'nss' 'glibc')
|
||||
options=('!strip' '!debug')
|
||||
|
||||
source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz"
|
||||
"weflow.desktop"
|
||||
"icon.png")
|
||||
|
||||
sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340'
|
||||
'2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7'
|
||||
'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33')
|
||||
|
||||
package() {
|
||||
install -dm755 "${pkgdir}/opt/${pkgname}"
|
||||
|
||||
cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}"
|
||||
|
||||
install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/"
|
||||
install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
|
||||
}
|
||||
BIN
resources/installer/linux/icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
59
resources/installer/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="weflow"
|
||||
APP_EXEC="weflow"
|
||||
OPT_DIR="/opt/$APP_NAME"
|
||||
BIN_LINK="/usr/bin/$APP_NAME"
|
||||
DESKTOP_DIR="/usr/share/applications"
|
||||
ICON_DIR="/usr/share/pixmaps"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 开始安装 $APP_NAME..."
|
||||
|
||||
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||
rm -rf "$OPT_DIR"
|
||||
mkdir -p "$OPT_DIR"
|
||||
cp -r ./* "$OPT_DIR/"
|
||||
chmod -R 755 "$OPT_DIR"
|
||||
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||
|
||||
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||
|
||||
echo "📝 正在创建桌面快捷方式..."
|
||||
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
[Desktop Entry]
|
||||
Name=WeFlow
|
||||
Exec=$OPT_DIR/$APP_EXEC %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=$APP_NAME
|
||||
StartupWMClass=WeFlow
|
||||
Comment=A local WeChat database decryption and analysis tool
|
||||
Categories=Utility;
|
||||
EOF
|
||||
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
|
||||
echo "🖼️ 正在安装图标..."
|
||||
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
else
|
||||
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||
fi
|
||||
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
echo "🔄 更新桌面数据库..."
|
||||
update-desktop-database "$DESKTOP_DIR"
|
||||
fi
|
||||
|
||||
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||
9
resources/installer/linux/weflow.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=WeFlow
|
||||
Comment=一个本地的微信聊天记录导出和年度报告应用
|
||||
Exec=/usr/bin/weflow %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=weflow
|
||||
StartupWMClass=WeFlow
|
||||
Categories=Utility;
|
||||
BIN
resources/key/linux/x64/xkey_helper_linux
Executable file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
BIN
resources/key/macos/universal/xkey_helper
Normal file
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
Executable file → Normal file
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
57
scripts/prepare-electron-runtime.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll',
|
||||
];
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath);
|
||||
const targetExists = fs.existsSync(targetPath);
|
||||
|
||||
if (targetExists) {
|
||||
const target = fs.statSync(targetPath);
|
||||
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
|
||||
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
|
||||
|
||||
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let copiedCount = 0;
|
||||
|
||||
for (const name of runtimeNames) {
|
||||
const sourcePath = path.join(sourceDir, name);
|
||||
const targetPath = path.join(targetDir, name);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
if (copyIfDifferent(sourcePath, targetPath)) {
|
||||
copiedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (copiedCount > 0) {
|
||||
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
149
src/App.tsx
@@ -17,12 +17,16 @@ import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import MyFootprintPage from './pages/MyFootprintPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AccountManagementPage from './pages/AccountManagementPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
@@ -35,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
|
||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
@@ -78,6 +80,7 @@ function App() {
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -103,44 +106,7 @@ function App() {
|
||||
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
|
||||
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkWaylandStatus = async () => {
|
||||
try {
|
||||
// 防止在非客户端环境报错,先检查 API 是否存在
|
||||
if (!window.electronAPI?.app?.checkWayland) return
|
||||
|
||||
// 通过 configService 检查是否已经弹过窗
|
||||
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
|
||||
|
||||
if (!hasWarned) {
|
||||
const isWayland = await window.electronAPI.app.checkWayland()
|
||||
if (isWayland) {
|
||||
setShowWaylandWarning(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查 Wayland 状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在协议同意之后并且已经进入主应用流程才检查
|
||||
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
|
||||
checkWaylandStatus()
|
||||
}
|
||||
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
|
||||
|
||||
const handleDismissWaylandWarning = async () => {
|
||||
try {
|
||||
// 记录到本地配置中,下次不再提示
|
||||
await window.electronAPI.config.set('waylandWarningShown', true)
|
||||
} catch (e) {
|
||||
console.error('保存 Wayland 提示状态失败:', e)
|
||||
}
|
||||
setShowWaylandWarning(false)
|
||||
}
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/settings') {
|
||||
@@ -162,7 +128,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -179,7 +145,7 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
@@ -200,7 +166,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -252,6 +218,7 @@ function App() {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
setAnalyticsConsent(consent)
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
@@ -266,18 +233,21 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
// 初始化数据收集(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
if (analyticsConsent === true) {
|
||||
cloudControl.initCloudControl()
|
||||
}
|
||||
}, [analyticsConsent])
|
||||
|
||||
// 记录页面访问
|
||||
// 记录页面访问(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
if (analyticsConsent !== true) return
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
}, [location.pathname, analyticsConsent])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
@@ -296,6 +266,7 @@ function App() {
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
@@ -312,10 +283,14 @@ function App() {
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
window.electronAPI.app.getVersion().then((currentVersion: string) => {
|
||||
const isMandatory = !!(info.minimumVersion && currentVersion &&
|
||||
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
|
||||
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
@@ -327,6 +302,21 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 监听通知点击导航事件
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return
|
||||
|
||||
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
// 导航到聊天页面,通过URL参数让ChatPage接收sessionId
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
@@ -419,7 +409,7 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
@@ -522,6 +512,11 @@ function App() {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
@@ -563,10 +558,6 @@ function App() {
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
@@ -580,9 +571,13 @@ function App() {
|
||||
<div className="agreement-notice">
|
||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||
<span className="agreement-notice-link">
|
||||
我们唯一的官方网站:
|
||||
官方网站:
|
||||
<a href="https://weflow.top" target="_blank" rel="noreferrer">
|
||||
https://weflow.top
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||
https://github.com/hicccc77/WeFlow
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -597,7 +592,7 @@ function App() {
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||
|
||||
<h4>4. 隐私保护</h4>
|
||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
@@ -654,41 +649,15 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWaylandWarning && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>环境兼容性提示 (Wayland)</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||
<br />
|
||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
updateInfo={updateInfo}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
|
||||
onUpdate={handleUpdateNow}
|
||||
onIgnore={handleIgnoreUpdate}
|
||||
isDownloading={isDownloading}
|
||||
isMandatory={!!(updateInfo as any)?.isMandatory}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
@@ -710,6 +679,7 @@ function App() {
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
@@ -722,10 +692,13 @@ function App() {
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -54,10 +54,11 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,20 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [internalStart, setInternalStart] = useState(startDate)
|
||||
const [internalEnd, setInternalEnd] = useState(endDate)
|
||||
|
||||
useEffect(() => {
|
||||
setInternalStart(startDate)
|
||||
setInternalEnd(endDate)
|
||||
}, [startDate, endDate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectingStart(true)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -63,8 +77,10 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - days)
|
||||
onStartDateChange(start.toISOString().split('T')[0])
|
||||
onEndDateChange(end.toISOString().split('T')[0])
|
||||
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
|
||||
const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||
onStartDateChange(startStr)
|
||||
onEndDateChange(endStr)
|
||||
}
|
||||
setIsOpen(false)
|
||||
setTimeout(() => onRangeComplete?.(), 0)
|
||||
@@ -89,38 +105,46 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
if (selectingStart) {
|
||||
onStartDateChange(dateStr)
|
||||
if (endDate && dateStr > endDate) {
|
||||
onEndDateChange('')
|
||||
setInternalStart(dateStr)
|
||||
if (internalEnd && dateStr > internalEnd) {
|
||||
setInternalEnd('')
|
||||
}
|
||||
setSelectingStart(false)
|
||||
} else {
|
||||
if (dateStr < startDate) {
|
||||
onStartDateChange(dateStr)
|
||||
onEndDateChange(startDate)
|
||||
} else {
|
||||
onEndDateChange(dateStr)
|
||||
let finalStart = internalStart
|
||||
let finalEnd = dateStr
|
||||
|
||||
if (dateStr < internalStart) {
|
||||
finalStart = dateStr
|
||||
finalEnd = internalStart
|
||||
}
|
||||
|
||||
setInternalStart(finalStart)
|
||||
setInternalEnd(finalEnd)
|
||||
|
||||
setSelectingStart(true)
|
||||
setIsOpen(false)
|
||||
|
||||
onStartDateChange(finalStart)
|
||||
onEndDateChange(finalEnd)
|
||||
setTimeout(() => onRangeComplete?.(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const isInRange = (day: number) => {
|
||||
if (!startDate || !endDate) return false
|
||||
if (!internalStart || !internalEnd) return false
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr >= startDate && dateStr <= endDate
|
||||
return dateStr >= internalStart && dateStr <= internalEnd
|
||||
}
|
||||
|
||||
const isStartDate = (day: number) => {
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr === startDate
|
||||
return dateStr === internalStart
|
||||
}
|
||||
|
||||
const isEndDate = (day: number) => {
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr === endDate
|
||||
return dateStr === internalEnd
|
||||
}
|
||||
|
||||
const isToday = (day: number) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 2400;
|
||||
z-index: 9200;
|
||||
}
|
||||
|
||||
.export-date-range-dialog {
|
||||
@@ -192,6 +192,149 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.open .export-date-range-time-trigger {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-trigger-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.export-date-range-time-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 24;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.export-date-range-time-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-item,
|
||||
.export-date-range-time-option {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(var(--primary-rgb), 0.28);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-item {
|
||||
min-width: 52px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-date-range-time-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-time-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.export-date-range-time-column-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.export-date-range-time-column-list {
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.export-date-range-time-option {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import {
|
||||
EXPORT_DATE_RANGE_PRESETS,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
createDateRangeByPreset,
|
||||
createDefaultDateRange,
|
||||
formatCalendarMonthTitle,
|
||||
formatDateInputValue,
|
||||
isSameDay,
|
||||
parseDateInputValue,
|
||||
startOfDay,
|
||||
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||
panelMonth: Date
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
|
||||
|
||||
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
|
||||
const bounds = resolveBounds(minDate, maxDate)
|
||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||
|
||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (value.preset === 'custom' && !value.useAllTime) {
|
||||
const { start, end } = value.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...value,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(value)
|
||||
}
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (value.useAllTime) {
|
||||
return {
|
||||
preset: value.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: bounds.minDate,
|
||||
end: bounds.maxDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
|
||||
|
||||
// Set default times: start at 00:00:00, end at 23:59:59
|
||||
nextStart.setHours(0, 0, 0, 0)
|
||||
nextEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
return {
|
||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||
useAllTime: value.useAllTime,
|
||||
preset: value.preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
|
||||
onClose,
|
||||
onConfirm
|
||||
}: ExportDateRangeDialogProps) {
|
||||
// Helper: Format date only (YYYY-MM-DD) for the date input field
|
||||
const formatDateOnly = (date: Date): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
// Helper: Format time only (HH:mm) for the time input field
|
||||
const formatTimeOnly = (date: Date): string => {
|
||||
const h = `${date.getHours()}`.padStart(2, '0')
|
||||
const m = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||
const [dateInput, setDateInput] = useState({
|
||||
start: formatDateInputValue(value.dateRange.start),
|
||||
end: formatDateInputValue(value.dateRange.end)
|
||||
start: formatDateOnly(value.dateRange.start),
|
||||
end: formatDateOnly(value.dateRange.end)
|
||||
})
|
||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||
|
||||
// Default times: start at 00:00, end at 23:59
|
||||
const [timeInput, setTimeInput] = useState({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
|
||||
const startTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
const endTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||
setDraft(nextDraft)
|
||||
setActiveBoundary('start')
|
||||
setDateInput({
|
||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||
start: formatDateOnly(nextDraft.dateRange.start),
|
||||
end: formatDateOnly(nextDraft.dateRange.end)
|
||||
})
|
||||
// For preset-based selections (not custom), use default times 00:00 and 23:59
|
||||
// For custom selections, preserve the time from value.dateRange
|
||||
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
} else {
|
||||
setTimeInput({
|
||||
start: formatTimeOnly(nextDraft.dateRange.start),
|
||||
end: formatTimeOnly(nextDraft.dateRange.end)
|
||||
})
|
||||
}
|
||||
setOpenTimeDropdown(null)
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [maxDate, minDate, open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDateInput({
|
||||
start: formatDateInputValue(draft.dateRange.start),
|
||||
end: formatDateInputValue(draft.dateRange.end)
|
||||
start: formatDateOnly(draft.dateRange.start),
|
||||
end: formatDateOnly(draft.dateRange.end)
|
||||
})
|
||||
// Don't sync timeInput here - it's controlled by the time picker
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!openTimeDropdown) return
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
const activeContainer = openTimeDropdown === 'start'
|
||||
? startTimeSelectRef.current
|
||||
: endTimeSelectRef.current
|
||||
if (!activeContainer?.contains(target)) {
|
||||
setOpenTimeDropdown(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpenTimeDropdown(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [openTimeDropdown])
|
||||
|
||||
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||
const clampStartDate = useCallback((targetDate: Date) => {
|
||||
const start = startOfDay(targetDate)
|
||||
if (!bounds) return start
|
||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
||||
return start
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
const clampEndDate = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
if (!bounds) return end
|
||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
||||
return end
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
|
||||
const setRangeStart = useCallback((targetDate: Date) => {
|
||||
const start = clampStartDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: nextEnd
|
||||
end: prev.dateRange.end
|
||||
},
|
||||
panelMonth: toMonthStart(start)
|
||||
}
|
||||
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
|
||||
const end = clampEndDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
end: end
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
|
||||
const previewRange = bounds
|
||||
? { start: bounds.minDate, end: bounds.maxDate }
|
||||
: createDefaultDateRange()
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByPreset(preset)
|
||||
}, minDate, maxDate).dateRange
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
|
||||
setActiveBoundary('start')
|
||||
}, [bounds, maxDate, minDate])
|
||||
|
||||
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return null
|
||||
const hours = Number(matched[1])
|
||||
const minutes = Number(matched[2])
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||
return { hours, minutes }
|
||||
}
|
||||
|
||||
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
|
||||
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
|
||||
|
||||
const parsedTime = parseTimeValue(timeStr)
|
||||
if (!parsedTime) return
|
||||
|
||||
setDraft(prev => {
|
||||
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
|
||||
const newDate = new Date(dateObj)
|
||||
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
...prev.dateRange,
|
||||
[boundary]: newDate
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
|
||||
setActiveBoundary(boundary)
|
||||
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
|
||||
}, [])
|
||||
|
||||
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
|
||||
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
|
||||
hours: boundary === 'start' ? 0 : 23,
|
||||
minutes: boundary === 'start' ? 0 : 59
|
||||
}
|
||||
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
|
||||
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
|
||||
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
|
||||
}, [timeInput, updateBoundaryTime])
|
||||
|
||||
const renderTimeDropdown = (boundary: ActiveBoundary) => {
|
||||
const currentTime = timeInput[boundary]
|
||||
const parsedCurrent = parseTimeValue(currentTime) ?? {
|
||||
hours: boundary === 'start' ? 0 : 23,
|
||||
minutes: boundary === 'start' ? 0 : 59
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-time-dropdown-header">
|
||||
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
|
||||
<strong>{currentTime}</strong>
|
||||
</div>
|
||||
<div className="export-date-range-time-quick-list">
|
||||
{QUICK_TIME_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
|
||||
onClick={() => updateBoundaryTime(boundary, option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-time-columns">
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">小时</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{HOUR_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-hour-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">分钟</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{MINUTE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-minute-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
|
||||
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
|
||||
|
||||
const commitStartFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.start)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.start)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, start: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.start)) {
|
||||
const parsedTime = parseTimeValue(timeInput.start)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
setRangeStart(parsed)
|
||||
}, [dateInput.start, setRangeStart])
|
||||
setRangeStart(parsedDate)
|
||||
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.end)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, end: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.end)) {
|
||||
const parsedTime = parseTimeValue(timeInput.end)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
setRangeEnd(parsed)
|
||||
}, [dateInput.end, setRangeEnd])
|
||||
setRangeEnd(parsedDate)
|
||||
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((delta: number) => {
|
||||
setDraft(prev => ({
|
||||
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
|
||||
}, [])
|
||||
|
||||
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||
// Use time from timeInput state (which is updated by the time picker)
|
||||
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return { hours: 0, minutes: 0 }
|
||||
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
|
||||
}
|
||||
|
||||
if (activeBoundary === 'start') {
|
||||
setRangeStart(targetDate)
|
||||
const newStart = new Date(targetDate)
|
||||
const time = parseTime(timeInput.start)
|
||||
newStart.setHours(time.hours, time.minutes, 0, 0)
|
||||
setRangeStart(newStart)
|
||||
setActiveBoundary('end')
|
||||
setOpenTimeDropdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDraft(prev => {
|
||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
})
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
|
||||
const newEnd = new Date(targetDate)
|
||||
const time = parseTime(timeInput.end)
|
||||
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
|
||||
if (pickedStart <= start) {
|
||||
newEnd.setHours(23, 59, 59, 999)
|
||||
setTimeInput(prev => ({ ...prev, end: '23:59' }))
|
||||
} else {
|
||||
newEnd.setHours(time.hours, time.minutes, 59, 999)
|
||||
}
|
||||
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: newEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}))
|
||||
setActiveBoundary('start')
|
||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||
setOpenTimeDropdown(null)
|
||||
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||
|
||||
const isRangeModeActive = !draft.useAllTime
|
||||
const modeText = isRangeModeActive
|
||||
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitStartFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
|
||||
ref={startTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('start')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'start'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitEndFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
|
||||
ref={endTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('end')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'end'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-dialog-btn primary"
|
||||
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||
onClick={() => {
|
||||
// Validate: end time should not be earlier than start time
|
||||
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
|
||||
setDateInputError({ start: true, end: true })
|
||||
return
|
||||
}
|
||||
onConfirm(cloneExportDateRangeSelection(draft))
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
|
||||
format?: string
|
||||
avatars?: boolean
|
||||
dateRange?: ExportDateRangeSelection
|
||||
fileNamingMode?: configService.ExportFileNamingMode
|
||||
media?: configService.ExportDefaultMediaConfig
|
||||
voiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
] as const
|
||||
|
||||
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
|
||||
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
|
||||
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
|
||||
]
|
||||
|
||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||
|
||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||
@@ -56,17 +62,21 @@ export function ExportDefaultsSettingsForm({
|
||||
layout = 'stacked'
|
||||
}: ExportDefaultsSettingsFormProps) {
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
@@ -75,10 +85,11 @@ export function ExportDefaultsSettingsForm({
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultFileNamingMode(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
@@ -90,11 +101,13 @@ export function ExportDefaultsSettingsForm({
|
||||
setExportDefaultFormat(savedFormat || 'excel')
|
||||
setExportDefaultAvatars(savedAvatars ?? true)
|
||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setExportDefaultMedia(savedMedia ?? {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
@@ -112,15 +125,19 @@ export function ExportDefaultsSettingsForm({
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect])
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
|
||||
|
||||
const notify = (text: string, success = true) => {
|
||||
onNotify?.(text, success)
|
||||
@@ -222,6 +239,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -245,6 +263,50 @@ export function ExportDefaultsSettingsForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>导出文件命名方式</label>
|
||||
<span className="form-hint">控制导出文件名是否包含时间范围</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFileNamingModeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>Excel 列显示</label>
|
||||
@@ -257,6 +319,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
@@ -292,7 +355,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<div className="form-group media-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出媒体内容</label>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="media-default-grid">
|
||||
@@ -352,6 +415,20 @@ export function ExportDefaultsSettingsForm({
|
||||
/>
|
||||
表情包
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.files}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, files: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -172,6 +174,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.year-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
messageDates,
|
||||
loadingDates = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showYearMonthPicker ? (
|
||||
{viewMode === 'month' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
{monthNames.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'year' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
|
||||