From 1d3979dc9d82a29c5a99d63b4f6930f9fcca71f6 Mon Sep 17 00:00:00 2001 From: tuigou Date: Wed, 9 Sep 2026 17:54:02 +0800 Subject: [PATCH] =?UTF-8?q?Linux=20=E6=94=AF=E6=8C=81=EF=BC=88Ubuntu=2026.?= =?UTF-8?q?04=20=E5=AE=9E=E6=B5=8B=EF=BC=89=E2=80=94=E2=80=94install.py=20?= =?UTF-8?q?find=5Fasar=20=E5=A2=9E=E5=8A=A0=20/opt/ZCode=E3=80=81/usr/lib/?= =?UTF-8?q?zcode=20=E7=AD=89=20deb/rpm=20=E5=80=99=E9=80=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=20/opt=E3=80=81/usr/lib=E3=80=81/usr/local/l?= =?UTF-8?q?ib=20=E4=B8=80=E5=B1=82=E5=85=9C=E5=BA=95=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E7=AD=89=20win32=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E9=9D=9E=20sudo=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=81=87=20root=20=E5=B1=9E=E4=B8=BB=20asar=20=E7=BB=99?= =?UTF-8?q?=E5=87=BA=20sudo=20=E6=8C=87=E5=BC=95=E8=80=8C=E9=9D=9E=20trace?= =?UTF-8?q?back=EF=BC=9Bsudo=20=E5=AE=89=E8=A3=85=E6=94=AF=E6=8C=81?= =?UTF-8?q?=EF=BC=9Areal=5Fhome()=20=E6=8C=89=20SUDO=5FUSER=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=9C=9F=E5=AE=9E=E7=94=A8=E6=88=B7=E5=AE=B6=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=EF=BC=8C=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95/MCP=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C//usage=20=E5=91=BD=E4=BB=A4=E7=85=A7?= =?UTF-8?q?=E5=B8=B8=E8=90=BD=20~/.zcode=EF=BC=8C=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=89=AF=E6=9C=AC=E4=B8=8E=20config=20=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=90=8E=20chown=20=E5=9B=9E=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E6=88=B7=EF=BC=88=E6=B3=B5=E4=B8=8E=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=BB=A5=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E8=AF=BB=E5=86=99=EF=BC=8Casar=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=20root=20=E5=B1=9E=E4=B8=BB=EF=BC=89=EF=BC=9Bpatch=5Finstall.p?= =?UTF-8?q?y=20zcode=5Fexe=5Ffor=20=E5=A2=9E=E5=8A=A0=20Linux=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80=20/zcode=EF=BC=88=E8=AF=AD=E6=B3=95=E8=87=AA?= =?UTF-8?q?=E6=A3=80=E5=9C=A8=20Linux=20=E7=9C=9F=E6=AD=A3=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=8CELECTRON=5FRUN=5FAS=5FNODE=20=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=20exit=3D0=EF=BC=89=EF=BC=8Cinstall=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=AF=E5=86=99=E6=80=A7=E9=A2=84=E6=A3=80=E5=8F=8B?= =?UTF-8?q?=E5=A5=BD=E6=8B=A6=E6=88=AA=E6=9D=83=E9=99=90=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=8CL()=20=E8=AF=AD=E8=A8=80=E5=9B=9E=E9=80=80=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20sudo=20=E5=9C=BA=E6=99=AF=EF=BC=9Binstall=5Fmonitor?= =?UTF-8?q?.py=20=E7=BC=BA=E7=9C=81=20asar=20=E5=A2=9E=E5=8A=A0=20Linux=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=EF=BC=9Bfuse=20=E5=AE=9E=E6=B5=8B=20Embedded?= =?UTF-8?q?AsarIntegrityValidation=3D0=E3=80=81RunAsNode=3D1=20=E6=BB=A1?= =?UTF-8?q?=E8=B6=B3=E6=B3=A8=E5=85=A5=E5=89=8D=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.py | 98 +++++++++++++++++++++++++++++++++++++++++++--- install_monitor.py | 3 ++ patch_install.py | 28 ++++++++++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/install.py b/install.py index 589e9c6..64251b1 100644 --- a/install.py +++ b/install.py @@ -22,7 +22,8 @@ overlay 副本刷新后由泵 2 秒内热重载;改了 inject-main.cjs(泵)才需要重启 ZCode。 ZCode 安装位置自动探测:环境变量 ZCODE_ASAR → 当前平台常见安装位置下找 resources\\app.asar -(Windows:D:\\ZCode 等;macOS:/Applications、~/Applications 下的 ZCode.app); +(Windows:D:\\ZCode 等;macOS:/Applications、~/Applications 下的 ZCode.app; +Linux:/opt/ZCode、/usr/lib/zcode 等 deb/rpm 布局); 失败且终端可交互时询问,或用 --asar 指定。非默认位置首次安装成功后路径记住在 config.json (asar_path 字段),之后的安装/卸载一律免传 --asar。 """ @@ -34,9 +35,36 @@ from pathlib import Path HERE = Path(__file__).parent.resolve() -DATA_DIR = Path.home() / ".zcode" / "zcode-token-usage-statusbar" # 标准安装的数据/运行目录 -ZCODE_CONFIG = Path.home() / ".zcode" / "cli" / "config.json" -COMMANDS_DIR = Path.home() / ".zcode" / "commands" + + +def _sudo_user(): + """sudo 运行时的真实用户名(POSIX;非 sudo / Windows 返回 None)。""" + if sys.platform != "win32" and os.environ.get("SUDO_USER"): + try: + if os.geteuid() == 0: + return os.environ["SUDO_USER"] + except AttributeError: + pass + return None + + +def real_home(): + """真实用户家目录:sudo 运行时 Path.home() 是 root 的家,而数据目录/MCP 注册/ + /usage 命令必须落在真实用户的 ~/.zcode(ZCode 客户端与泵都以普通用户身份运行)。 + Linux 上 /opt 等系统安装位置写入需要 root,这是本函数存在的唯一场景。""" + sudo_user = _sudo_user() + if sudo_user: + try: + import pwd + return Path(pwd.getpwnam(sudo_user).pw_dir) + except (KeyError, OSError): + pass + return Path.home() + + +DATA_DIR = real_home() / ".zcode" / "zcode-token-usage-statusbar" # 标准安装的数据/运行目录 +ZCODE_CONFIG = real_home() / ".zcode" / "cli" / "config.json" +COMMANDS_DIR = real_home() / ".zcode" / "commands" MCP_NAME = "zcode-token-usage-statusbar" # MCP server 注册名(与仓库名一致;老安装叫 token-usage/zusage,自动迁移) MCP_NAME_OLD = ("token-usage", "zusage") # 历史注册名 RUNTIME_FILES = ("inject-main.cjs", "overlay.js", "zusage.py", "usage_mcp.py") @@ -47,6 +75,13 @@ "/Applications/ZCode.app/Contents/Resources/app.asar", "~/Applications/ZCode.app/Contents/Resources/app.asar", ] +elif sys.platform.startswith("linux"): + ASAR_CANDIDATES = [ + "/opt/ZCode/resources/app.asar", # deb/rpm 官方包默认布局(/zcode) + "/opt/zcode/resources/app.asar", + "/usr/lib/zcode/resources/app.asar", + "/usr/local/ZCode/resources/app.asar", + ] else: ASAR_CANDIDATES = [ r"D:\ZCode\resources\app.asar", @@ -96,6 +131,22 @@ def find_asar(): p = ch / "resources" / "app.asar" if p.is_file(): return p + elif sys.platform.startswith("linux"): + # 兜底:扫 /opt、/usr/lib、/usr/local/lib 一层子目录,目录名含 zcode 即命中 + for base in ("/opt", "/usr/lib", "/usr/local/lib"): + b = Path(base) + if not b.is_dir(): + continue + try: + children = sorted(b.iterdir()) + except OSError: + continue + for ch in children: + if "zcode" not in ch.name.lower(): + continue + p = ch / "resources" / "app.asar" + if p.is_file(): + return p return None @@ -141,6 +192,20 @@ def remember_asar(asar, dev): print(L(f"[目标] 已记住安装位置 → {cfg}", f"[target] install location remembered -> {cfg}")) +def chown_to_user(path): + """sudo 运行时把产物归还给真实用户(数据目录、config、注册文件); + asar 属系统安装(root 所有),保持原属主不动。非 sudo 是 no-op。""" + sudo_user = _sudo_user() + if not sudo_user: + return + try: + import pwd + st = pwd.getpwnam(sudo_user) + os.chown(path, st.pw_uid, st.pw_gid) + except (KeyError, OSError, AttributeError): + pass + + def copy_runtime(dry): """复制运行时四件套到数据目录(无条件覆盖:overlay 的 mtime 变化会让泵 2 秒内热重载)。""" for name in RUNTIME_FILES: @@ -150,8 +215,10 @@ def copy_runtime(dry): f"[runtime] copying {len(RUNTIME_FILES)} files -> {DATA_DIR}")) if not dry: DATA_DIR.mkdir(parents=True, exist_ok=True) + chown_to_user(DATA_DIR) for name in RUNTIME_FILES: shutil.copy2(HERE / name, DATA_DIR / name) + chown_to_user(DATA_DIR / name) return True @@ -192,7 +259,9 @@ def prepare_config(dry, dev): f"[config] generating {cfg} (python_path = {sys.executable})")) if not dry: cfg.parent.mkdir(parents=True, exist_ok=True) + chown_to_user(cfg.parent) cfg.write_text(json.dumps(vals, indent=2, ensure_ascii=False), encoding="utf-8") + chown_to_user(cfg) return True @@ -223,9 +292,11 @@ def register_mcp(dry, dev): print(L(f"[MCP] 注册 {MCP_NAME} → {usage_mcp}", f"[mcp] registering {MCP_NAME} -> {usage_mcp}") + removed_note) if not dry: ZCODE_CONFIG.parent.mkdir(parents=True, exist_ok=True) + chown_to_user(ZCODE_CONFIG.parent) if ZCODE_CONFIG.is_file(): shutil.copy2(ZCODE_CONFIG, ZCODE_CONFIG.with_suffix(".json.zusage.bak")) ZCODE_CONFIG.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + chown_to_user(ZCODE_CONFIG) return True @@ -332,12 +403,27 @@ def main(): asar = Path(args.asar) if args.asar else (find_asar() or ask_asar()) if not asar or not asar.is_file(): - example = (r"E:\Apps\ZCode\resources\app.asar" if sys.platform == "win32" - else "/Applications/ZCode.app/Contents/Resources/app.asar") + if sys.platform == "win32": + example = r"E:\Apps\ZCode\resources\app.asar" + elif sys.platform.startswith("linux"): + example = "/opt/ZCode/resources/app.asar" + else: + example = "/Applications/ZCode.app/Contents/Resources/app.asar" print(L(f"找不到 app.asar。用 --asar 指定,例如:python install.py --asar {example}", f"app.asar not found. Specify it with --asar, e.g.: python install.py --asar {example}")) return 1 print(L(f"[目标] {asar}", f"[target] {asar}")) + if (sys.platform != "win32" and os.geteuid() != 0 + and not os.access(asar, os.W_OK)): + hint = " ".join(sys.argv[1:]) + print(L(f"[权限] {asar.parent} 不可写(系统级安装位置,Linux 上常见),需要 sudo:", + f"[permission] {asar.parent} is not writable (a system-level install location, common on Linux); sudo is required:")) + print(L(f" sudo {Path(sys.executable).name} {Path(__file__).name} {hint}".rstrip(), + f" sudo {Path(sys.executable).name} {Path(__file__).name} {hint}".rstrip())) + print(L("(数据目录/MCP 注册会自动落到你的用户家目录,产物归属普通用户;asar 本身保持 root 属主)", + "(the data dir / MCP registration go to your real user home with user ownership; the asar keeps its root ownership)")) + if not args.dry_run: + return 1 if not args.dry_run: pi.set_target(asar) if not args.dev: diff --git a/install_monitor.py b/install_monitor.py index 28b667c..ff2fb5f 100644 --- a/install_monitor.py +++ b/install_monitor.py @@ -25,8 +25,11 @@ HERE = Path(__file__).parent.resolve() IS_MAC = sys.platform == "darwin" +IS_LINUX = sys.platform.startswith("linux") +# 缺省仅兜底(patch_install.py 拉起时必传 argv[2]):按平台取常见安装位置 ASAR = Path(sys.argv[2]) if len(sys.argv) > 2 else ( Path("/Applications/ZCode.app/Contents/Resources/app.asar") if IS_MAC + else Path("/opt/ZCode/resources/app.asar") if IS_LINUX else Path(r"D:\ZCode\resources\app.asar")) RUNTIME_DIR = Path(sys.argv[3]) if len(sys.argv) > 3 else HERE TMP = ASAR.with_name("app.asar.zusage.tmp") diff --git a/patch_install.py b/patch_install.py index ae655c0..bd36a34 100644 --- a/patch_install.py +++ b/patch_install.py @@ -75,7 +75,20 @@ def L(zh, en): global _LANG if _LANG is None: _LANG = "zh" - for base in (RUNTIME, Path.home() / ".zcode" / "zcode-token-usage-statusbar"): + # sudo 运行时(Linux 系统级安装)config 在真实用户的家目录,Path.home() 会指到 root + sudo_home = None + if sys.platform != "win32" and os.environ.get("SUDO_USER"): + try: + if os.geteuid() == 0: + import pwd + sudo_home = Path(pwd.getpwnam(os.environ["SUDO_USER"]).pw_dir) + except (KeyError, OSError, AttributeError): + pass + bases = [RUNTIME, + Path.home() / ".zcode" / "zcode-token-usage-statusbar"] + if sudo_home: + bases.append(sudo_home / ".zcode" / "zcode-token-usage-statusbar") + for base in bases: try: lang = json.loads((base / "config.json").read_text(encoding="utf-8")).get("lang") if lang in ("zh", "en"): @@ -97,12 +110,15 @@ def set_runtime(path): def zcode_exe_for(asar_path): """从 asar 位置推 ZCode 可执行文件(仅语法自检用):Windows 布局 /ZCode.exe, - macOS bundle 布局 /MacOS/ZCode;其它平台返回 None(语法检查自动跳过)。""" + macOS bundle 布局 /MacOS/ZCode,Linux 布局 /zcode(deb/rpm 官方包,/opt/ZCode); + 其它平台返回 None(语法检查自动跳过)。""" root = asar_path.parent.parent if IS_WIN: return root / "ZCode.exe" if IS_MAC: return root / "MacOS" / "ZCode" + if sys.platform.startswith("linux"): + return root / "zcode" return None @@ -285,6 +301,14 @@ def cleanup_legacy_bak(): def install(finalize=False): """返回 True=已安装/已指向当前目录,False=失败或待收尾。""" assert LOADER.exists(), L(f"loader 缺失:{LOADER}", f"loader missing: {LOADER}") + if not os.access(ASAR, os.W_OK) or not os.access(ASAR.parent, os.W_OK): + print(L(f"[权限] app.asar 或所在目录不可写(Linux 上 /opt 等系统位置需要 root):{ASAR}", + f"[permission] app.asar or its directory is not writable (Linux system locations like /opt need root): {ASAR}")) + print(L("请用 sudo 重跑(推荐走一键安装,会自动处理目标定位与数据目录归属):", + "Please re-run with sudo (the one-shot installer is recommended; it resolves the target and data-dir ownership automatically):")) + print(L(" sudo python install.py # 自动探测(含 /opt/ZCode)", + " sudo python install.py # auto-detects (including /opt/ZCode)")) + return False entry = entry_bytes_of(ASAR) stripped = ZUSAGE_LINE_RE.sub(b"", entry) # 剥离任何旧注入行(含指向旧目录的) if stripped + INJECT_LINE.encode() == entry: