Repository navigation
Expand file tree
/
Copy pathpatch_zcode_greeting.py
More file actions
268 lines (229 loc) · 11 KB
/
Copy pathpatch_zcode_greeting.py
File metadata and controls
268 lines (229 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ZCode 客户端 UI 欢迎语随机化补丁工具。
ZCode 更新后 app.asar 会被还原为官方版,此脚本一键重新打补丁:
检测 -> 备份 -> 解包 -> 定位渲染文件 -> 修改(日期种子随机欢迎语) -> 重打包 -> 替换。
幂等:已补丁则直接跳过。失败则中止,不破坏原文件。
用法:
python patch_zcode_greeting.py
python patch_zcode_greeting.py --asar "D:/ZCode/resources/app.asar"
python patch_zcode_greeting.py --check # 仅检查是否已打补丁
依赖: Node.js (npx 调用 @electron/asar)
"""
import argparse
import glob
import os
import re
import shutil
import subprocess
import sys
# ---------- 默认配置(可用 CLI 参数覆盖) ----------
# 常见安装位置依次探测(都找不到再用 --asar 显式指定)
ASAR_CANDIDATES = [
r"D:\ZCode\resources\app.asar",
os.path.join(os.path.expanduser("~"), "AppData", "Local", "Programs", "ZCode", "resources", "app.asar"),
r"C:\Program Files\ZCode\resources\app.asar",
]
DEFAULT_ASAR = next((c for c in ASAR_CANDIDATES if os.path.exists(c)), ASAR_CANDIDATES[0])
DEFAULT_WORK = os.path.join(os.path.expanduser("~"), ".zcode", "asar_work")
# 锁定 major 版本:npx 不加版本号每次拉最新并自动执行,registry 侧投毒即在本机运行
# (想换版本用环境变量 ASAR_PACKAGE 覆盖,如 @electron/asar@3.4.0)
ASAR_PACKAGE = os.environ.get("ASAR_PACKAGE", "@electron/asar@3")
# 六个时段的变体文案 key(与 IntlProvider 中新增的定义对应)
PERIOD_POOLS = {
"morningEarly": 3,
"morning": 3,
"noon": 3,
"afternoon": 3,
"evening": 4,
"lateNight": 3,
}
# 新增文案定义(注入 IntlProvider 的 lateNight 之后)
VARIANT_TEXTS = {
"morningEarly.v1": "早安。天刚亮,一切都来得及。",
"morningEarly.v2": "清晨好,慢慢来,不着急。",
"morningEarly.v3": "早上好,新的一天从想清楚要做什么开始。",
"morning.v1": "上午好,有什么想让我帮忙的吗?",
"morning.v2": "上午好,先想想今天最要紧的一件事。",
"morning.v3": "上午好,精力正足,适合啃硬骨头。",
"noon.v1": "中午好,先吃饭,天大的事饭后再谈。",
"noon.v2": "中午好,稍微歇一歇,下午才有力气。",
"noon.v3": "中午好,吃饱了才有力气干活。",
"afternoon.v1": "下午好,来,接着干。",
"afternoon.v2": "下午好,进度如何了?",
"afternoon.v3": "下午好,犯困就先喝口水,然后继续。",
"evening.v1": "晚上好,白天的事先放一放。",
"evening.v2": "晚上好,收个尾就可以休息了。",
"evening.v3": "晚上好,今天辛苦了,想聊点什么?",
"evening.v4": "晚上好,夜还长,不急这一时。",
"lateNight.v1": "这么晚了还在忙,早点休息吧。",
"lateNight.v2": "深夜好,安静的时光适合想清楚事情。",
"lateNight.v3": "夜深了,别忘了照顾好自己。",
}
MARKER = "getFullYear()*367" # 补丁已打标记
FUNC_RE = re.compile(
r"function ([A-Za-z_$][\w$]*)\(e=new Date\)\{let ([A-Za-z_$][\w$]*)=e\.getHours\(\);return [^}]*chat\.empty\.greeting\.lateNight[^}]*\}"
)
ANCHOR_RE = re.compile(r'"chat\.empty\.greeting\.lateNight":`[^`]*`')
def log(msg):
print("[patch] " + msg, flush=True)
def is_patched(asar_path):
with open(asar_path, "rb") as f:
return MARKER.encode() in f.read()
def run(cmd, **kw):
log("运行: " + (" ".join(cmd) if isinstance(cmd, list) else str(cmd)))
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw)
def build_new_func(match):
name = match.group(1)
h = match.group(2)
pools = []
for p in ["morningEarly", "morning", "noon", "afternoon", "evening", "lateNight"]:
keys = ", ".join("`chat.empty.greeting.%s.v%d`" % (p, i) for i in range(1, PERIOD_POOLS[p] + 1))
pools.append("[%s]" % keys)
prime_chain = (
"%(h)s>=5&&%(h)s<9?11:%(h)s>=9&&%(h)s<12?13:%(h)s>=12&&%(h)s<14?17:"
"%(h)s>=14&&%(h)s<18?19:%(h)s>=18&&%(h)s<23?23:29" % {"h": h}
)
return (
"function %s(e=new Date){let %s=e.getHours(),r=%s>=5&&%s<9?%s:%s>=9&&%s<12?%s:%s>=12&&%s<14?%s:"
"%s>=14&&%s<18?%s:%s>=18&&%s<23?%s:%s;return r[(e.getFullYear()*367+(e.getMonth()+1)*31+e.getDate())*"
"(%s)%%r.length]}"
% (name, h, h, h, pools[0], h, h, pools[1], h, h, pools[2],
h, h, pools[3], h, h, pools[4], pools[5], prime_chain)
)
def read_text_strict(path):
"""二进制读取后严格 utf-8 解码:解码失败立即中止,绝不以替换字符改写
(旧实现 errors='replace' 会把无效字节永久替换成 U+FFFD,损坏原文件)。"""
raw = open(path, "rb").read()
try:
return raw.decode("utf-8")
except UnicodeDecodeError as e:
raise RuntimeError("%s 不是有效 UTF-8(%s);为避免损坏文件,已中止" % (path, e))
def write_text_binary(path, text):
"""按字节写回:不经文本模式换行翻译(旧实现会把原 LF 改写成 CRLF)。"""
with open(path, "wb") as f:
f.write(text.encode("utf-8"))
def patch_files(extract_dir):
assets = os.path.join(extract_dir, "out", "renderer", "assets")
styles = sorted(glob.glob(os.path.join(assets, "styles-*.js")))
intls = sorted(glob.glob(os.path.join(assets, "IntlProvider-*.js")))
if not styles or not intls:
raise RuntimeError("未找到 styles-*.js 或 IntlProvider-*.js,渲染文件结构可能已变化")
# 1. 改渲染函数:在所有 styles-*.js 里找目标函数(多 chunk 时不再只碰第一个)
patched = 0
for sp in styles:
data = read_text_strict(sp)
m = FUNC_RE.search(data)
if not m:
continue
log("渲染逻辑文件: %s(函数 %s)" % (os.path.basename(sp), m.group(1)))
new_func = build_new_func(m)
data = data[:m.start()] + new_func + data[m.end():]
write_text_binary(sp, data)
patched += 1
# 校验:引用 key 都有定义(直接基于我们生成的函数文本,不再脆弱地找右花括号)
used = re.findall(r"`(chat\.empty\.greeting\.[^`]+)`", new_func)
for ip in intls:
idata0 = read_text_strict(ip)
missing = [k for k in used if ('"%s":' % k) not in idata0]
if missing:
raise RuntimeError("缺失文案定义: %s" % missing)
log("校验通过: %d 个引用 key 全部有定义" % len(used))
if patched == 0:
raise RuntimeError("所有 styles-*.js 中都未找到按时段选择问候语的函数"
"(模式: function X(e=new Date){let t=e.getHours();return ...})")
log("渲染函数已替换为日期种子随机版(%d 个文件)" % patched)
# 2. 注入变体文案到含锚点的每个 IntlProvider chunk
injected = 0
for ip in intls:
idata = read_text_strict(ip)
anchor = ANCHOR_RE.search(idata)
if not anchor:
continue
inject = "".join(
',"chat.empty.greeting.%s":`%s`' % (k, v) for k, v in VARIANT_TEXTS.items()
)
idata = idata[: anchor.end()] + inject + idata[anchor.end():]
write_text_binary(ip, idata)
log("文案文件: %s(已注入 %d 条)" % (os.path.basename(ip), len(VARIANT_TEXTS)))
injected += 1
if injected == 0:
raise RuntimeError("未找到 chat.empty.greeting.lateNight 文案锚点")
return True
def main():
parser = argparse.ArgumentParser(description="ZCode 客户端 UI 欢迎语随机化补丁工具")
parser.add_argument("--asar", default=DEFAULT_ASAR, help="app.asar 路径(默认: %(default)s)")
parser.add_argument("--work", default=DEFAULT_WORK, help="解包工作目录(默认: %(default)s)")
parser.add_argument("--check", action="store_true", help="仅检查是否已打补丁,不修改")
parser.add_argument("--keep-work", action="store_true",
help="成功后保留解包工作目录与 app_new.asar(默认自动清理)")
parser.add_argument("--force", action="store_true",
help="允许清空任意 --work 目录(默认只清空形如本工具工作区的目录)")
args = parser.parse_args()
asar_path = args.asar
work_root = args.work
backup_path = asar_path + ".bak"
if not os.path.exists(asar_path):
log("错误: 找不到 %s" % asar_path)
sys.exit(1)
if is_patched(asar_path):
log("检测到补丁已生效,无需操作")
return
if args.check:
log("未打补丁")
sys.exit(1)
log("检测到 app.asar 未打补丁,开始修复...")
# 备份原版(若不存在)
if not os.path.exists(backup_path):
shutil.copy2(asar_path, backup_path)
log("已备份原版到 %s" % backup_path)
# 解包到干净的工作目录(带防护:--work 指到重要目录时不再无条件整树删除)
if os.path.exists(work_root):
entries = os.listdir(work_root)
staging_like = (not entries) or ("out" in entries and len(entries) <= 3)
if staging_like or args.force:
shutil.rmtree(work_root)
else:
raise RuntimeError(
"拒绝清空 %s:目录内容不像本工具的解包工作区(含 %d 个条目)。"
"确认无误后加 --force 重试。" % (work_root, len(entries)))
os.makedirs(work_root)
npx = "npx.cmd" if os.name == "nt" else "npx"
run([npx, "--yes", ASAR_PACKAGE, "extract", asar_path, work_root])
log("解包完成")
patch_files(work_root)
# 重打包
new_asar = os.path.join(os.path.dirname(work_root.rstrip("\\/")), "app_new.asar")
if os.path.exists(new_asar):
os.remove(new_asar)
run([npx, "--yes", ASAR_PACKAGE, "pack", work_root, new_asar,
"--unpack", "**/*.{node,dll,exe}"])
log("重打包完成: %s" % new_asar)
# 校验新包
with open(new_asar, "rb") as f:
blob = f.read()
if MARKER.encode() not in blob:
raise RuntimeError("新 asar 校验失败: 未包含补丁标记")
log("新 asar 校验通过")
# 替换
shutil.copy2(new_asar, asar_path)
log("已替换 %s" % asar_path)
# 清理中间产物(旧实现每次遗留几十~几百 MB 的 app_new.asar 与解包目录)
if not args.keep_work:
for target in (new_asar, work_root):
try:
if os.path.isdir(target):
shutil.rmtree(target)
elif os.path.exists(target):
os.remove(target)
except OSError as e:
log("清理 %s 失败(可手动删除): %s" % (target, e))
log("已清理中间产物(保留备份 %s)" % backup_path)
log("完成。重启 ZCode 客户端即可看到效果。")
if __name__ == "__main__":
try:
main()
except Exception as e:
print("[patch] 失败: %s" % e, file=sys.stderr)
print("[patch] 原文件未受影响(先备份后修改),可手动用 .bak 备份还原。", file=sys.stderr)
sys.exit(1)