From 4b95824da5f7cc4e655b2d55b5e9cb386867e1d5 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Fri, 28 Aug 2026 17:15:17 +0800 Subject: [PATCH 01/45] =?UTF-8?q?chore:=20=E5=8A=A0=20.gitattributes=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8D=A2=E8=A1=8C=E7=AC=A6=E4=B8=BA=20LF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓库没有 .gitattributes,core.autocrlf 也是默认的 false, git 直接存盘上的字节。Windows 上编辑器保存一次就会把文件改成 CRLF, `git status` 于是把整个仓库标成已修改——实测一次 37 个文件、5608 行增删, 而 `git diff --ignore-all-space` 是空的,一行真实内容都没变。 真正的改动会被淹没在里面,评审也没法看。 加上 `* text=auto eol=lf` 之后,git 在计算对象时归一化换行符, 工作区即使是 CRLF 也不再算差异。 顺带修正 docs/_sidebar.md:它是仓库里唯一一个存成 CRLF 的文件, 本次归一化为 LF,内容没有变化。 对 install 这个文件尤其重要——它是用户 source 执行的 shell 脚本, 跑在 Linux 上,CRLF 会导致 bad interpreter 之类的报错。 --- .gitattributes | 27 +++++++++++++++++++++++++++ docs/_sidebar.md | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..35984ef --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# 统一按 LF 处理换行符。 +# +# 为什么需要:这个仓库里 core.autocrlf 默认是 false 且此前没有 .gitattributes, +# git 会原样存盘上的字节。Windows 上某些编辑器保存文件时会顺手改成 CRLF, +# 于是 `git status` 会把整个仓库都标成已修改——实测一次就是 37 个文件、 +# 5608 行增删,而 `git diff --ignore-all-space` 是空的,一行真实内容都没变。 +# 这会淹没真正的改动,评审也没法看。 +# +# text=auto 让 git 在计算对象时把换行符归一成 LF,工作区是 CRLF 也不再算差异; +# eol=lf 保证检出到工作区时也是 LF。 +# +# 对 install 这个文件尤其重要:它是用户 `source` 执行的 shell 脚本, +# 跑在 Linux 上,CRLF 会导致 bad interpreter 之类的报错。 +* text=auto eol=lf + +# 二进制文件不做任何转换。仓库目前没有二进制文件, +# 这几行是防止以后加了图片等资源被误转换。 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text eol=lf +*.zip binary +*.gz binary +*.tar binary +*.deb binary diff --git a/docs/_sidebar.md b/docs/_sidebar.md index cbe55fa..05bb8df 100755 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,3 +1,3 @@ -- [一键安装](./README.md) -- 高效工具 +- [一键安装](./README.md) +- 高效工具 - [Md编辑器Typora](./install/Typora.md) \ No newline at end of file From a17226fecb006e70f1632b4c1f76c3c38f93d1a5 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Fri, 9 Oct 2026 04:25:53 -0700 Subject: [PATCH 02/45] =?UTF-8?q?fix(ros):=20=E4=BF=AE=E5=A4=8D=E5=9B=BD?= =?UTF-8?q?=E5=86=85=20ROS2=20=E9=95=9C=E5=83=8F=20404=E3=80=81=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E5=AF=BC=E5=85=A5=E5=A4=B1=E8=B4=A5=E7=AD=89=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社区和 issue 里大量反馈 Ubuntu 20.04/18.04 一键安装 foxy/galactic 失败、 满屏 404,以及「导入密钥失败」。2026-10 逐个镜像实测后定位到以下原因: 1. 清华/中科大/华为/MirrorZ 的 ROS2 源里 bionic/focal/buster/xenial 的 索引停在 2024~2025 年,对应 deb 包却已被删除:apt update 正常, 一安装就 404。阿里云与官方源同步正常(2026-09)。 - 这几个系统的 ROS2 只保留阿里云 + 官方源,并新增阿里云选项 (阿里云 ROS1 源停在 2021 年,只用于 ROS2) - jammy/noble/resolute/bullseye/bookworm 的国内源实测正常, resolute 补上国内源 - 新增运行时检测:换源后对每个 ROS 版本抽一个 ros-base 包发 HEAD 请求,确认 404 就自动排除该镜像换下一个,以后镜像再过期 也能自己绕开 2. gitee 的 raw 地址现在会 302 跳到 raw.giteeusercontent.com,原来的 `curl -s` 不跟随跳转,拿到一段 HTML,gpg 报 no valid OpenPGP data。 改为 `curl -fsSL` 下载并校验是 PGP 公钥后,用 gpg --dearmor 写入 /etc/apt/trusted.gpg.d/ros.gpg(不再依赖已弃用的 apt-key); 密钥服务器兜底改走 hkp://keyserver.ubuntu.com:80 + 完整指纹。 3. 换源重试逻辑:原来只有一个 ROS 版本时(len(ros_pkg) > 1)会被 误判为失败;用户重选同一个源时提示「四次换源都失败」并退出。 改为最多 6 次、自动排除失败镜像。 4. 安装遇到 --fix-broken 时用 "foxy(ROS2)" 当包名/字典键,必然 KeyError;改为用真实包名。下载中断时刷新索引后 --fix-missing 重试一次;出现 404 时提示换镜像源。 5. MirrorZ 选项误标为「中山大学开源软件镜像站」,更正。 测试用例里 ROS 官方源的序号随之从 5 变为 6。 Co-Authored-By: Claude Opus 5.5 --- tests/fish_install_test.yaml | 12 +- tools/tool_install_ros.py | 434 +++++++++++++++++++---------------- 2 files changed, 243 insertions(+), 203 deletions(-) diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index c79959b..ce5f1cf 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -49,7 +49,7 @@ chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 5, desc: "支持的第5个ros版本" } - { choose: 2, desc: "基础版(小)" } @@ -60,7 +60,7 @@ chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "foxy(ROS2)" } - { choose: 2, desc: "基础版(小)" } @@ -69,7 +69,7 @@ chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 3, desc: "noetic(ROS1)" } - { choose: 2, desc: "基础版(小)" } # - name: "Install_ROS_focal_foxy" @@ -77,7 +77,7 @@ # chooses: # - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } # - { choose: 2, desc: "不更换系统源再继续安装" } -# - { choose: 4, desc: "ROS官方源" } +# - { choose: 6, desc: "ROS官方源" } # - { choose: 1, desc: "支持的第一个ros版本" } # - { choose: 2, desc: "基础版(小)" } @@ -88,7 +88,7 @@ chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } @@ -99,7 +99,7 @@ chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 1d78e14..0dc1eb3 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pickle import NONE +import re from .base import BaseTool from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask from .base import osversion @@ -194,6 +194,9 @@ def get_desktop_version(name): "https://gitee.com/fishros/rosdistro/raw/master/ros.asc", "https://raw.githubusercontent.com/ros/rosdistro/master/ros.asc", ] +# ROS 官方 GPG 公钥完整指纹, 密钥服务器兜底时使用(短指纹容易撞) +ros_key_fingerprint = "C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654" +ros_keyring = "/etc/apt/trusted.gpg.d/ros.gpg" ros_mirror_dic = { "tsinghua": { @@ -212,6 +215,10 @@ def get_desktop_version(name): "ROS1": "https://repo.huaweicloud.com/ros/ubuntu/", "ROS2": "https://repo.huaweicloud.com/ros2/ubuntu/", }, + # 阿里云的 ROS1 源停在 2021 年, 不要用; ROS2 源与官方同步, 是 EOL 系统(focal/bionic...)唯一可用的国内源 + "aliyun": { + "ROS2": "https://mirrors.aliyun.com/ros2/ubuntu/", + }, "packages.ros": { "ROS1": "http://packages.ros.org/ros/ubuntu/", "ROS2": "http://packages.ros.org/ros2/ubuntu/", @@ -380,11 +387,12 @@ def get_desktop_version(name): } +# 2026-10 实测: 清华/中科大/华为/MirrorZ 的 ROS2 源里 bionic/focal/buster/xenial 的索引 +# 停在 2024~2025 年, 但对应的 deb 包已被删除, apt 能看到 ros-foxy-* 却下载 404; +# 阿里云与官方源同步正常. 这几个系统的 ROS2 只保留阿里云和官方源. ros2_dist_dic = { "bionic": { - "tsinghua", - "mirrorz", - "huawei", + "aliyun", "packages.ros", }, "bookworm": { @@ -392,6 +400,7 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "bullseye": { @@ -399,12 +408,11 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "buster": { - "tsinghua", - "ustc", - "huawei", + "aliyun", "packages.ros", }, "cosmic": { @@ -417,10 +425,7 @@ def get_desktop_version(name): "packages.ros", }, "focal": { - "tsinghua", - "ustc", - "huawei", - "mirrorz", + "aliyun", "packages.ros", }, "jessie": { @@ -434,6 +439,7 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "noble": { @@ -441,9 +447,15 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "resolute": { + "tsinghua", + "ustc", + "huawei", + "mirrorz", + "aliyun", "packages.ros", }, "stretch": { @@ -466,10 +478,7 @@ def get_desktop_version(name): "packages.ros", }, "xenial": { - "tsinghua", - "ustc", - "huawei", - "mirrorz", + "aliyun", "packages.ros", }, "utopic": { @@ -490,111 +499,126 @@ def __init__(self): self.type = BaseTool.TYPE_INSTALL self.author = "小鱼" - def get_mirror_by_code(self, code, arch="amd64", first_choose="tsinghua"): + def get_mirror_by_code(self, code, arch="amd64", first_choose="tsinghua", excluded=None): """ 获取镜像通过系统版本号 + excluded: {"ROS1": set(), "ROS2": set()} 已确认不可用的镜像, 跳过 """ + excluded = excluded or {} ros1_choose_queue = [first_choose, "tsinghua", "ustc", "huawei", "packages.ros"] - ros2_choose_queue = [first_choose, "tsinghua", "ustc", "huawei", "packages.ros"] + ros2_choose_queue = [ + first_choose, + "tsinghua", + "ustc", + "huawei", + "aliyun", + "packages.ros", + ] # armhf架构,优先使用官方源 if arch == "armhf": - ros2_choose_queue = ["packages.ros", "tsinghua", "ustc", "huawei"] + ros2_choose_queue = ["packages.ros", "tsinghua", "ustc", "huawei", "aliyun"] mirror = [] - # 确认源里有对应的系统的,比如jammy - if code in ros_dist_dic.keys(): - for item in ros1_choose_queue: - if item in ros_dist_dic[code]: - mirror.append(ros_mirror_dic[item]["ROS1"]) - break - # 确认源里有对应的系统的,比如jammy - if code in ros2_dist_dic.keys(): - for item in ros2_choose_queue: - if item in ros2_dist_dic[code]: - mirror.append(ros_mirror_dic[item]["ROS2"]) + for version, dist_dic, queue in ( + ("ROS1", ros_dist_dic, ros1_choose_queue), + ("ROS2", ros2_dist_dic, ros2_choose_queue), + ): + # 确认源里有对应的系统的,比如jammy + if code not in dist_dic.keys(): + continue + for item in queue: + if item in excluded.get(version, ()): + continue + if item in dist_dic[code] and version in ros_mirror_dic[item]: + mirror.append(ros_mirror_dic[item][version]) break - # if code in ['focal']: - # mirror.append(ros_mirror_dic['packages.ros']['ROS2']) return mirror def select_mirror(self): """ 让用户选择镜像源 """ - # 检查当前系统是否支持中科大镜像 codename = osversion.get_codename() + mirror_names = { + "ustc": "中科大镜像源 (推荐国内用户使用)", + "tsinghua": "清华镜像源 (容易被封禁)", + "huawei": "华为镜像源", + "mirrorz": "MirrorZ 校园网联合镜像站", + "aliyun": "阿里云镜像源 (仅ROS2, Ubuntu20.04/18.04 装ROS2请选它)", + } supported_mirrors = [] - - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "ustc" in ros_dist_dic.get(codename, []) or "ustc" in ros2_dist_dic.get( - codename, [] + for mirror in mirror_names.keys(): + if mirror in ros_dist_dic.get(codename, ()) or mirror in ros2_dist_dic.get( + codename, () ): - supported_mirrors.append("ustc") - - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "tsinghua" in ros_dist_dic.get( - codename, [] - ) or "tsinghua" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("tsinghua") - - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "huawei" in ros_dist_dic.get( - codename, [] - ) or "huawei" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("huawei") - - # 添加 mirrorz 镜像源支持 - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "mirrorz" in ros_dist_dic.get( - codename, [] - ) or "mirrorz" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("mirrorz") - - # 如果系统支持多个镜像源,则让用户选择 - if len(supported_mirrors) > 1: - mirror_dict = {} - count = 1 - for mirror in supported_mirrors: - if mirror == "ustc": - mirror_dict[count] = "中科大镜像源 (推荐国内用户使用)" - elif mirror == "tsinghua": - mirror_dict[count] = "清华镜像源 (容易被封禁)" - elif mirror == "huawei": - mirror_dict[count] = "华为镜像源" - elif mirror == "mirrorz": - mirror_dict[count] = "中山大学开源软件镜像站 (试运行)" - count += 1 - - mirror_dict[count] = "ROS官方源 (国外用户或需要最新版本时使用)" - - code, result = ChooseTask( - mirror_dict, - "检测到您的系统支持多个ROS镜像源,请选择您想要使用的ROS镜像源(默认清华):", - ).run() - if code == 0: - return "tsinghua" # 默认返回清华源 - elif code == count: - return "packages.ros" # 官方源 - else: - # 根据选择返回对应的镜像源 - for key, value in mirror_dict.items(): - if key == code: - if "中科大" in value: - return "ustc" - elif "清华" in value: - return "tsinghua" - elif "华为" in value: - return "huawei" - elif "中山大" in value: - return "mirrorz" + supported_mirrors.append(mirror) - else: - # 系统只支持默认的清华源 - PrintUtils.print_info("您的系统默认使用清华镜像源") - return "tsinghua" + # 只有官方源或只有一个国内源时不再询问 + if len(supported_mirrors) <= 1: + default = supported_mirrors[0] if supported_mirrors else "packages.ros" + PrintUtils.print_info("您的系统默认使用镜像源: {}".format(default)) + return default - return "tsinghua" + mirror_dict = {} + for count, mirror in enumerate(supported_mirrors, 1): + mirror_dict[count] = mirror_names[mirror] + official = len(supported_mirrors) + 1 + mirror_dict[official] = "ROS官方源 (国外用户或需要最新版本时使用)" + + code, result = ChooseTask( + mirror_dict, + "检测到您的系统支持多个ROS镜像源,请选择您想要使用的ROS镜像源:", + ).run() + if code == official: + return "packages.ros" + if code in mirror_dict: + return supported_mirrors[code - 1] + return supported_mirrors[0] + + def write_ros_source(self, arch, codename, mirrors): + source_data = "" + for mirror in mirrors: + source_data += "deb [arch={}] {} {} main\n".format(arch, mirror, codename) + FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") + FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + + def find_stale_mirrors(self, ros_pkg): + """ + 国内镜像有时只剩索引、包文件已被删除, apt update 正常, 一安装就满屏 404. + 这里对每个 ROS 版本抽一个 ros-base 包发 HEAD 请求, 确认真的能下载. + 返回 {"ROS1"/"ROS2": 镜像名}, 只包含确认 404 的镜像 + """ + stale = {} + for distro_name in set(ros_pkg.values()): + # get_all_instsll_ros_pkgs 返回的是 "foxy" 这样的版本名, 换成真实包名 + pkg = "ros-{}-ros-base".format(distro_name) + result = CmdTask( + "apt-get download --print-uris {} 2>/dev/null | head -n 1".format(pkg), 20 + ).run() + uri = re.findall(r"'([^']+\.deb)'", " ".join(result[1])) + if not uri: + continue + uri = uri[0] + check = CmdTask( + "curl -sIL -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(uri), + 20, + ).run() + http_code = "".join(check[1]).strip() + # 只认明确的 403/404/410, 网络超时(000)等不算, 避免误判 + if http_code not in ("403", "404", "410"): + continue + plain_uri = uri.replace("https://", "http://") + for name, urls in ros_mirror_dic.items(): + for version, url in urls.items(): + if plain_uri.startswith(url.replace("https://", "http://")): + stale[version] = name + PrintUtils.print_warn( + "检测到{}镜像源[{}]索引已过期, 包文件下载返回{}: {}".format( + version, name, http_code, uri + ) + ) + return stale def add_source(self): """ @@ -603,62 +627,71 @@ def add_source(self): arch = AptUtils.getArch() if arch == None: return False + codename = osversion.get_codename() # 让用户选择镜像源 selected_mirror = self.select_mirror() PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) + excluded = {"ROS1": set(), "ROS2": set()} - # add source 1 - mirrors = self.get_mirror_by_code( - osversion.get_codename(), arch=arch, first_choose=selected_mirror - ) - PrintUtils.print_info("根据您的系统和选择,为您推荐安装源为{}".format(mirrors)) - source_data = "" - for mirror in mirrors: - source_data += "deb [arch={}] {} {} main\n".format( - arch, mirror, osversion.get_codename() - ) - FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") - FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) - - ros_pkg = self.get_all_instsll_ros_pkgs() - if ros_pkg and len(ros_pkg) > 1: - PrintUtils.print_success( - "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" - ) - return - - # 如果第一次尝试失败,让用户重新选择镜像源 - PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") - retry_mirror = self.select_mirror() - while retry_mirror != selected_mirror: - PrintUtils.print_info("您重新选择的镜像源: {}".format(retry_mirror)) + for attempt in range(6): mirrors = self.get_mirror_by_code( - osversion.get_codename(), arch=arch, first_choose=retry_mirror - ) - PrintUtils.print_info( - "根据您的系统和选择,为您推荐安装源为{}".format(mirrors) + codename, arch=arch, first_choose=selected_mirror, excluded=excluded ) - source_data = "" - for mirror in mirrors: - source_data += "deb [arch={}] {} {} main\n".format( - arch, mirror, osversion.get_codename() - ) - FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") - FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + if not mirrors: + break + PrintUtils.print_info("根据您的系统和选择,为您推荐安装源为{}".format(mirrors)) + self.write_ros_source(arch, codename, mirrors) + ros_pkg = self.get_all_instsll_ros_pkgs() - if ros_pkg and len(ros_pkg) > 1: - PrintUtils.print_success( - "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" - ) - return - else: - PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") - retry_mirror = self.select_mirror() - else: - PrintUtils.print_error( - "您选择了相同的镜像源,四次换源后都失败了,请及时联系小鱼获取解决方案并处理!" - ) + if ros_pkg: + stale = self.find_stale_mirrors(ros_pkg) + if not stale: + PrintUtils.print_success( + "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" + ) + return True + for version, name in stale.items(): + excluded[version].add(name) + PrintUtils.print_warn("自动切换到下一个可用的镜像源重试...") + continue + + # apt update 失败或搜不到 ROS 包, 让用户换一个镜像源再试 + PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") + for version in excluded.keys(): + excluded[version].add(selected_mirror) + selected_mirror = self.select_mirror() + PrintUtils.print_info("您重新选择的镜像源: {}".format(selected_mirror)) + for version in excluded.keys(): + excluded[version].discard(selected_mirror) + + PrintUtils.print_error( + "多次换源后仍然失败,请携带 /tmp/fishros_install.log 到鱼香社区 https://fishros.org.cn/forum 一键安装板块反馈!" + ) + return False + + def download_ros_key(self): + """ + 依次尝试各个公钥地址, 下载并校验通过后返回本地文件路径. + gitee raw 现在会 302 跳到 raw.giteeusercontent.com, curl 必须带 -L, 否则拿到的是一段 HTML + """ + key_file = "/tmp/fishros_ros.asc" + PrintUtils.print_success(tr.tr("正在挑选最快的密钥服务:{}").format(key_urls)) + for key_url in AptUtils.get_fast_url(key_urls): + CmdTask("rm -f {}".format(key_file), 3).run() + result = CmdTask( + "curl -fsSL --retry 2 --max-time 30 {} -o {}".format(key_url, key_file), 40 + ).run() + if result[0] != 0: + continue + check = CmdTask( + "grep -q 'BEGIN PGP PUBLIC KEY BLOCK' {}".format(key_file), 3 + ).run() + if check[0] == 0: + PrintUtils.print_success(tr.tr("已自动选择最快密钥服务:{}").format(key_url)) + return key_file + PrintUtils.print_warn("{} 返回的不是有效公钥, 尝试下一个地址".format(key_url)) + return None def add_key(self): PrintUtils.print_success( @@ -671,60 +704,39 @@ def add_key(self): AptUtils.install_pkg("curl") AptUtils.install_pkg("gnupg2") - # add key - PrintUtils.print_success(tr.tr("正在挑选最快的密钥服务:{}").format(key_urls)) - key_url = AptUtils.get_fast_url(key_urls) - if not key_url: - PrintUtils.print_error(tr.tr("获取密钥失败")) - return - key_url = key_url[0] - PrintUtils.print_success(tr.tr("已自动选择最快密钥服务:{}").format(key_url)) - - has_apt_key = CmdTask("command -v apt-key", 10).run()[0] == 0 - if has_apt_key: - cmd_result = CmdTask( - "curl -s {} | sudo apt-key add -".format(key_url) - ).run() - if cmd_result[0] != 0: - cmd_result = CmdTask( - "curl -s {} | sudo apt-key add -".format(key_url) - ).run() - # 针对近期密钥更新问题 - CmdTask( - "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654", - 10, - ).run() - if cmd_result[0] != 0: - PrintUtils.print_info( - tr.tr("导入密钥失败,开始更换导入方式并二次尝试...") - ) - cmd_result = CmdTask( - "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654", - 10, - ).run() - else: - PrintUtils.print_info( - tr.tr("检测到系统未提供apt-key,切换为gpg keyring导入方式...") - ) + cmd_result = (1, [], []) + key_file = self.download_ros_key() + if key_file: + # 统一导入 trusted.gpg.d, 新旧系统都认, 不依赖已弃用的 apt-key cmd_result = CmdTask( - "curl -fsSL {} | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ros.gpg".format( - key_url - ) + "sudo gpg --batch --yes --dearmor -o {0} {1} && sudo chmod 644 {0}".format( + ros_keyring, key_file + ), + 20, ).run() - if cmd_result[0] == 0: - CmdTask("sudo chmod 644 /etc/apt/trusted.gpg.d/ros.gpg", 10).run() + if cmd_result[0] != 0 and CmdTask("command -v apt-key", 10).run()[0] == 0: + cmd_result = CmdTask("sudo apt-key add {}".format(key_file), 20).run() - # 针对trusted.gpg.d问题解决方案 - if FileUtils.check_result(cmd_result, ["trusted.gpg.d"]): - cmd_result = CmdTask( - "curl -s {} | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/ros.gpg --import".format( - key_url - ) - ).run() + if cmd_result[0] != 0: + PrintUtils.print_info(tr.tr("导入密钥失败,开始更换导入方式并二次尝试...")) + # 11371 端口经常不通, 走 80 端口; 用完整指纹 + tmp_keyring = "/tmp/fishros_ros_keyring.gpg" cmd_result = CmdTask( - "sudo chmod 644 /etc/apt/trusted.gpg.d/ros.gpg", 10 + "sudo rm -f {1} && " + "sudo gpg --batch --no-default-keyring --keyring {1} " + "--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys {0} && " + "sudo gpg --batch --no-default-keyring --keyring {1} --export {0} " + "| sudo tee {2} > /dev/null && sudo chmod 644 {2}".format( + ros_key_fingerprint, tmp_keyring, ros_keyring + ), + 60, ).run() + if cmd_result[0] != 0: + PrintUtils.print_error( + tr.tr("获取密钥失败") + + ", 后续 apt update 可能报 NO_PUBKEY, 请检查网络后重试" + ) return cmd_result def check_sys_source(self): @@ -919,25 +931,53 @@ def choose_and_install_ros(self): os_command=False, ).run() + # 个别包下载中断(网络抖动/镜像临时不可用)时, 刷新索引后补下一次 + if cmd_result[0] != 0 and FileUtils.check_result( + cmd_result, ["Unable to fetch some archives", "--fix-missing", "无法下载"] + ): + PrintUtils.print_warn("部分软件包下载失败, 刷新软件源后重试一次...") + CmdTask("sudo apt update", 100).run() + target = ( + dic_base[install_version] + if code == 2 + else RosVersions.get_desktop_version(install_version) + ) + cmd_result = CmdTask( + "sudo {} install --fix-missing {} -y".format(install_tool_apt, target), + 300, + os_command=False, + ).run() + # apt broken error if cmd_result[0] != 0: if FileUtils.check_result(cmd_result, ["--fix-broken install"]): CmdTask("sudo apt --fix-broken install -y").run() + # rosname 是 "foxy(ROS2)" 这种展示名, 包名要用 install_version 换算 if code == 2: cmd_result = CmdTask( "sudo {} install {} -y".format( - install_tool, dic_base[rosname] + install_tool_apt, dic_base[install_version] ), 300, os_command=False, ).run() elif code == 1: cmd_result = CmdTask( - "sudo {} install {} -y".format(install_tool, rosname), + "sudo {} install {} -y".format( + install_tool_apt, + RosVersions.get_desktop_version(install_version), + ), 300, os_command=False, ).run() + if cmd_result[0] != 0 and FileUtils.check_result( + cmd_result, [r"404\s+Not Found"] + ): + PrintUtils.print_warn( + "安装过程中出现 404, 说明当前ROS镜像源缺包, 请重新运行一键安装并换一个ROS镜像源(阿里云或ROS官方源)" + ) + # 安装额外的依赖 RosVersions.install_depend(install_version) From 7b06de46fcc54be108808def94540f6f0ef144b8 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Fri, 9 Oct 2026 04:25:53 -0700 Subject: [PATCH 03/45] =?UTF-8?q?fix(source):=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=8D=A2=E6=BA=90=E6=94=AF=E6=8C=81=20Debian=2011+/arm64=20?= =?UTF-8?q?=E4=B8=8E=20Ubuntu=2024.04=20deb822?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Debian 的镜像不区分架构,arm64 也用同一套地址(原来 arm64 拿到空 列表,测速后 fast_source[0] 抛 IndexError,见 issue #118) - Debian 11 起安全源路径改为 -security,Debian 12 起增加 non-free-firmware,按代号选模板;修掉镜像地址带尾部 / 时安全源 拼成 .../debian/-security 的问题 - Ubuntu 24.04+ 系统源在 sources.list.d/ubuntu.sources(deb822), 原来只删 sources.list 再新写一份,官方源仍然留着。现在检测到 ubuntu.sources 就直接改写它,原文件备份到 /etc/apt/ubuntu.sources.fishros.bak - 一个源都没找到时 result 未赋值导致 UnboundLocalError(社区 #4764) - 删掉 get_source_by_system 里 return 之后不可达的死代码, ubuntu-ports 列表去重并补中科大 Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_system_source.py | 285 ++++++++++------------------- 1 file changed, 95 insertions(+), 190 deletions(-) diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index ac1bc5e..9782495 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask from .base import osversion @@ -18,12 +19,27 @@ deb / -backports main restricted universe multiverse deb / -security main restricted universe multiverse """ -debian_amd64_sources_template = """ +# Debian 10 及更早: 安全源路径是 /updates +debian_old_sources_template = """ deb / main contrib non-free deb / -updates main contrib non-free deb / -backports main contrib non-free deb -security /updates main contrib non-free """ +# Debian 11 起: 安全源改为 -security +debian_amd64_sources_template = """ +deb / main contrib non-free +deb / -updates main contrib non-free +deb / -backports main contrib non-free +deb -security/ -security main contrib non-free +""" +# Debian 12 起: 固件单独拆到 non-free-firmware +debian_firmware_sources_template = """ +deb / main contrib non-free non-free-firmware +deb / -updates main contrib non-free non-free-firmware +deb / -backports main contrib non-free non-free-firmware +deb -security/ -security main contrib non-free non-free-firmware +""" ubuntu_ports_deb822_template = """ Types: deb URIs: / @@ -52,12 +68,16 @@ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg """ +# Ubuntu 24.04 起默认使用 deb822 格式的 ubuntu.sources, 不再用 sources.list +ubuntu_deb822_file = '/etc/apt/sources.list.d/ubuntu.sources' + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_CONFIG self.name = "一键更换系统源" self.author = '小鱼' + self.use_deb822 = False def add_ros_source(self): @@ -73,33 +93,45 @@ def add_ros_source(self): def clean_old_source(self): dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() + # 删除之前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起删掉, 就判断不出来了 + self.use_deb822 = os.path.exists(ubuntu_deb822_file) + backup = '/etc/apt/ubuntu.sources.fishros.bak' + if self.use_deb822 and not os.path.exists(backup): + CmdTask('sudo cp {} {}'.format(ubuntu_deb822_file, backup)).run() FileUtils.delete('/etc/apt/sources.list') - if code==2: + if code==2: print("删除一个资源文件") FileUtils.delete('/etc/apt/sources.list.d') - # fix add source failed before config system source + # fix add source failed before config system source CmdTask('sudo mkdir -p /etc/apt/sources.list.d').run() - + # 添加选择源的方式 PrintUtils.print_info("源选择方式说明:") PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") - + dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() + def get_debian_template(self, codename): + if codename in ('wheezy', 'jessie', 'stretch', 'buster'): + return debian_old_sources_template + if codename == 'bullseye': + return debian_amd64_sources_template + return debian_firmware_sources_template + def get_source_by_system(self,system,codename,arch,failed_sources=[], return_all=False): # 实际测试发现,阿里云虽然延时很低,但是带宽也低的离谱,一点都不用心,删掉了 ubuntu_amd64_sources = [ "https://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "https://mirror.sysu.edu.cn/ubuntu/", + "https://mirror.sysu.edu.cn/ubuntu", # "https://mirrors.aliyun.com/ubuntu", # "https://mirrors.163.com/ubuntu", "https://mirrors.ustc.edu.cn/ubuntu", "https://archive.ubuntu.com/ubuntu", "https://mirrors.kernel.org/ubuntu", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "http://mirror.sysu.edu.cn/ubuntu/", + "http://mirror.sysu.edu.cn/ubuntu", # "http://mirrors.aliyun.com/ubuntu", # "http://mirrors.163.com/ubuntu", "http://mirrors.ustc.edu.cn/ubuntu", @@ -107,141 +139,57 @@ def get_source_by_system(self,system,codename,arch,failed_sources=[], return_all "http://mirrors.kernel.org/ubuntu", ] ubuntu_ports_sources = [ - "https://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "https://mirror.sysu.edu.cn/ubuntu-ports/", + "https://mirror.sysu.edu.cn/ubuntu-ports", + "https://mirrors.ustc.edu.cn/ubuntu-ports", "https://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "http://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "http://mirror.sysu.edu.cn/ubuntu-ports/", + "http://mirror.sysu.edu.cn/ubuntu-ports", + "http://mirrors.ustc.edu.cn/ubuntu-ports", "http://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", ] - debian_amd64_sources = [ + # Debian 的镜像不区分架构, arm64/armhf 和 amd64 用同一套地址 + debian_sources = [ "https://mirrors.tuna.tsinghua.edu.cn/debian", - "https://mirror.sysu.edu.cn/debian/", + "https://mirror.sysu.edu.cn/debian", "https://mirrors.aliyun.com/debian", # "https://mirrors.163.com/debian", "https://mirrors.ustc.edu.cn/debian", "https://deb.debian.org/debian", - "https://mirrors.kernel.org/debian", "http://mirrors.tuna.tsinghua.edu.cn/debian", - "http://mirror.sysu.edu.cn/debian/", + "http://mirror.sysu.edu.cn/debian", "http://mirrors.aliyun.com/debian", # "http://mirrors.163.com/debian", "http://mirrors.ustc.edu.cn/debian", "http://deb.debian.org/debian", - "http://mirrors.kernel.org/debian", ] sources = [] template = ubuntu_amd64_sources_template if system=='ubuntu': if arch=='amd64': sources = ubuntu_amd64_sources - template = ubuntu_amd64_sources_template + template = ubuntu_amd64_deb822_template if self.use_deb822 else ubuntu_amd64_sources_template else: sources = ubuntu_ports_sources - template = ubuntu_ports_sources_template + template = ubuntu_ports_deb822_template if self.use_deb822 else ubuntu_ports_sources_template elif system=='debian': - if arch=='amd64': - template = debian_amd64_sources_template - sources = debian_amd64_sources + template = self.get_debian_template(codename) + sources = debian_sources + + sources = [source for source in sources if source not in failed_sources] + if len(sources) == 0: + return None, None PrintUtils.print_delay('搜索到可用源:{}'.format(sources),0.002) PrintUtils.print_delay('接下来将进行自动测速以为您选择最快的源:') fast_source = AptUtils.get_fast_url(sources) - - # 如果需要返回所有源和模板(不进行测速) - if return_all: - # 直接返回源列表和模板 - if len(failed_sources) > 0: - filtered_sources = [source for source in sources if source not in failed_sources] - return filtered_sources, template - return sources, template - - # 正常返回最快的源 - if len(failed_sources)>0: - PrintUtils.print_warn('接下来为您排除已经失败的源') - for source in fast_source: - if source in failed_sources: - PrintUtils.print_info('{} 已经测试失败,跳过!'.format(source)) - else: - return source,template - else: - return fast_source[0],template - return None,None - - for source in sources: - if "tsinghua" in source: - tsinghua_sources.append(source) - elif "ustc" in source: - ustc_sources.append(source) - elif "archive.ubuntu" in source or "deb.debian" in source: - official_sources.append(source) - elif "kernel" in source: - kernel_sources.append(source) - elif "aliyun" in source: - aliyun_sources.append(source) - else: - other_sources.append(source) - - # 添加到选择字典 - index = 1 - - # 添加清华源 - if tsinghua_sources: - for source in tsinghua_sources: - source_dict[index] = source - index += 1 - - # 添加中科大源 - if ustc_sources: - for source in ustc_sources: - source_dict[index] = source - index += 1 - - # 添加阿里云源 - if aliyun_sources: - for source in aliyun_sources: - source_dict[index] = source - index += 1 - - # 添加官方源 - if official_sources: - for source in official_sources: - source_dict[index] = source - index += 1 - - # 添加kernel源 - if kernel_sources: - for source in kernel_sources: - source_dict[index] = source - index += 1 - - # 添加其他源 - if other_sources: - for source in other_sources: - source_dict[index] = source - index += 1 - - PrintUtils.print_info("请选择您想使用的镜像源:") - code, source = ChooseTask(source_dict, "请选择一个镜像源").run() - - if not source: + if len(fast_source) == 0: return None, None - - return source, template - - # 去除末尾的斜杠 - if source.endswith("/"): - source = source[:-1] - - return source, template - + + # 手动选择时返回按测速排序后的全部源 + if return_all: + return fast_source, template + return fast_source[0], template + def replace_source(self,failed_sources=[]): arch = AptUtils.getArch() name = osversion.get_name() @@ -251,71 +199,54 @@ def replace_source(self,failed_sources=[]): elif name.find("debian")>=0: system = 'debian' else: + PrintUtils.print_error('当前系统{}暂不支持一键换源'.format(name)) return None - + if not codename: + PrintUtils.print_error('获取系统代号失败, 无法自动换源, 请检查 /etc/os-release') + return None + PrintUtils.print_delay('检测到当前系统:{} 架构:{} 代号:{},正在为你搜索适合的源...'.format(system,arch,codename)) - + # 根据用户选择的方式获取源 - if hasattr(self, 'source_method_code'): - if self.source_method_code == 2: - # 根据测速结果手动选择源 - sorted_sources, template = self.get_source_by_system(system, codename, arch, failed_sources, return_all=True) - if not sorted_sources: - return None - - # 创建选择字典 - source_dict = {} - for i, src in enumerate(sorted_sources, 1): - source_dict[i] = src - - PrintUtils.print_info("请从测速结果中选择您想使用的镜像源:") - code, source = ChooseTask(source_dict, "请选择一个镜像源").run() - - if not source: - return None - else: - # 自动测速选择源 - source, template = self.get_source_by_system(system, codename, arch, failed_sources) + if getattr(self, 'source_method_code', 1) == 2: + # 根据测速结果手动选择源 + sorted_sources, template = self.get_source_by_system(system, codename, arch, failed_sources, return_all=True) + if not sorted_sources: + return None + source_dict = {} + for i, src in enumerate(sorted_sources, 1): + source_dict[i] = src + PrintUtils.print_info("请从测速结果中选择您想使用的镜像源:") + code, source = ChooseTask(source_dict, "请选择一个镜像源").run() + if not source: + return None + PrintUtils.print_success('您选择的镜像源:{}'.format(source)) else: # 自动测速选择源 source, template = self.get_source_by_system(system, codename, arch, failed_sources) - - if not source: return None - - if hasattr(self, 'source_method_code'): - if self.source_method_code == 2: - PrintUtils.print_success('您选择的镜像源:{}'.format(source)) - else: - PrintUtils.print_success('为您选择最快镜像源:{}'.format(source)) - else: + if not source: return None PrintUtils.print_success('为您选择最快镜像源:{}'.format(source)) - - # 使用已修复的 FileUtils.new 方法 - FileUtils.new('/etc/apt/','sources.list',template.replace("",codename).replace('',source)) + + data = template.replace("",codename).replace('',source.rstrip('/')) + if self.use_deb822: + FileUtils.new('/etc/apt/sources.list.d/','ubuntu.sources',data) + else: + FileUtils.new('/etc/apt/','sources.list',data) return source - + def change_sys_source(self): self.clean_old_source() failed_sources = [] + result = (-1, [], []) source = self.replace_source(failed_sources) if source: PrintUtils.print_delay("替换镜像源完成,尝试进行更新....") result = CmdTask('sudo apt update',100).run() - - # 如果是手动选择源且更新失败,提示用户重新选择 - if result[0] != 0 and hasattr(self, 'source_method_code') and self.source_method_code == 2: - PrintUtils.print_warn("您选择的源更新失败,请重新选择其他源") - while result[0] != 0: - failed_sources.append(source) - source = self.replace_source(failed_sources) - if not source: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") - return - result = CmdTask('sudo apt update',100).run() - # 如果是自动测速选择源且更新失败,自动尝试其他源 - elif result[0] != 0: + if result[0] != 0: + if getattr(self, 'source_method_code', 1) == 2: + PrintUtils.print_warn("您选择的源更新失败,请重新选择其他源") while result[0] != 0: failed_sources.append(source) PrintUtils.print_warn("更新失败,尝试更换其他源") @@ -327,33 +258,7 @@ def change_sys_source(self): else: PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") -# # update -# PrintUtils.print_delay("替换完成,尝试第一次更新....") -# result = CmdTask('sudo apt update',100).run() -# # https error update second -# if result[0]!= 0 and FileUtils.check_result(result[1]+result[2],['Certificate verification','证书']): -# PrintUtils.print_delay("发生证书错误,尝试第二次更新....") -# FileUtils.delete('/etc/apt/sources.list') -# FileUtils.new('/etc/apt/','sources.list',source.replace("https://","http://").replace("",osversion.get_codename())) -# result = CmdTask('sudo apt update',100).run() -# if result[0]!=0: -# PrintUtils.print_info("更新失败,开始更换导入方式并三次尝试...") -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 0E98404D386FA1D9",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DCC9EFBF77E11517",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 54404762BBB6E853",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654",10).run() -# # sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys -# # result = CmdTask("apt-get install debian-keyring debian-archive-keyring",10).run() -# result = CmdTask("apt-key update",10).run() -# result = CmdTask('sudo apt update',100).run() -# if result[0]!=0: -# PrintUtils.print_info("""如果出现问题NO_PUBKEY XXXXXXXX,请手动运行添加指令:apt-key adv --keyserver keyserver.ubuntu.com --recv-keys XXXXXXXX -# 如:error: NO_PUBKEY 0E98404D386FA1D9 -# 运行指令:sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 0E98404D386FA1D9 -# """) -# # final check - - if result[0]==0: + if result[0]==0: PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") PrintUtils.print_info(result[1]) PrintUtils.print_success("镜像更新完成.....") @@ -362,4 +267,4 @@ def run(self): # 正式的运行 self.change_sys_source() # 添加 ROS Source - self.add_ros_source() \ No newline at end of file + self.add_ros_source() From 23028402363e98e702e45c193383f71496be5c6f Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Fri, 9 Oct 2026 04:25:53 -0700 Subject: [PATCH 04/45] =?UTF-8?q?fix(base):=20=E5=85=BC=E5=AE=B9=E6=97=A0?= =?UTF-8?q?=20sudo=20=E5=AE=B9=E5=99=A8=E3=80=81=E7=BC=BA=20distro=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E3=80=81=E6=AE=8B=E7=95=99=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker 容器里通常是 root 且没装 sudo,所有 "sudo xxx" 都会 command not found(issue #120)。以 root 运行且找不到 sudo 时自动 去掉命令里的 sudo 前缀 - python3-distro 装不上时(apt 源坏了/conda 环境)不再直接 ModuleNotFoundError 崩溃,改为读取 /etc/os-release 兜底 - Debian 11/12/13 版本号没有小数点,代号一律返回空串,导致 「暂不支持 debian12/13」(issue #88 #118),改为取 os-release 代号 - check_result 原来要求正则恰好匹配 1 次,而输出被拼成一整段,同一 错误出现多次就检测不到,改为匹配到即可 - install:上次运行被中断时 /tmp/fishinstall 残留且属主是 root,普通 用户再次下载会因权限不足静默失败,表现为「没有出现菜单直接退出」 或跑的是旧脚本(issue #74 #113)。每次先清理,下载失败给出明确提示; $UID 换成 $(id -u),在 sh 下也能用 Co-Authored-By: Claude Opus 5.5 --- install | 21 +++++++++++++------ tools/base.py | 58 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/install b/install index c5b078f..25d8f21 100644 --- a/install +++ b/install @@ -1,15 +1,24 @@ export FISHROS_URL=http://mirror.fishros.com/install/ +SUDO='' +if [ "$(id -u)" -ne 0 ];then + SUDO='sudo' +fi + +# 上次运行被中断时 /tmp/fishinstall 会残留且属主是 root, 普通用户再下载会因权限不足静默失败, +# 结果要么没有菜单直接退出, 要么跑的是旧脚本, 所以每次先清掉 +$SUDO rm -rf /tmp/fishinstall/ mkdir -p /tmp/fishinstall/tools/translation/assets -wget $FISHROS_URL/install.py -O /tmp/fishinstall/install.py 2>>/dev/null +wget -q $FISHROS_URL/install.py -O /tmp/fishinstall/install.py source /etc/profile -SUDO='' -if [ $UID -ne 0 ];then - SUDO='sudo' +if [ -s /tmp/fishinstall/install.py ]; then + $SUDO apt install python3-distro python3-yaml -y + $SUDO python3 /tmp/fishinstall/install.py +else + echo "下载一键安装脚本失败: ${FISHROS_URL}install.py" + echo "请检查网络后重试; 如果提示证书相关错误, 请先执行 sudo apt install -y ca-certificates" fi -$SUDO apt install python3-distro python3-yaml -y -$SUDO python3 /tmp/fishinstall/install.py $SUDO rm -rf /tmp/fishinstall/ if [ -f fishros ]; then diff --git a/tools/base.py b/tools/base.py index 89e5114..6ec0e66 100644 --- a/tools/base.py +++ b/tools/base.py @@ -5,6 +5,7 @@ import sys import time import subprocess +import shutil import importlib import locale from queue import Queue @@ -151,16 +152,36 @@ def GetOsVersion(): # to be removed after Ubuntu Xenial is out of support import sys - # @TODO 系统的python版本小于3.8,Conda版本>3.8 - if sys.version_info >= (3, 8): - import distro - else: - import platform as distro import locale import os import platform import subprocess + class _OsReleaseDistro: + """python3-distro 装不上时(apt 源坏了/conda 环境)的兜底, 直接读 /etc/os-release""" + + @staticmethod + def linux_distribution(full_distribution_name=False): + info = {} + for path in ("/etc/os-release", "/usr/lib/os-release"): + if os.path.exists(path): + with codecs.open(path, "r", encoding="utf-8") as f: + for line in f: + key, _, val = line.strip().partition("=") + info[key] = val.strip('"') + break + codename = info.get("VERSION_CODENAME") or info.get("UBUNTU_CODENAME", "") + return (info.get("ID", ""), info.get("VERSION_ID", ""), codename) + + # @TODO 系统的python版本小于3.8,Conda版本>3.8 + if sys.version_info >= (3, 8): + try: + import distro + except ImportError: + distro = _OsReleaseDistro + else: + import platform as distro + def _read_stdout(cmd): try: pop = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -255,7 +276,7 @@ def __init__(self, lsb_name, get_version_fn=None): elif hasattr(distro, "dist"): self.lsb_info = distro.dist() else: - self.lsb_info = None + self.lsb_info = _OsReleaseDistro.linux_distribution() def is_os(self): if self.lsb_info is None: @@ -289,7 +310,13 @@ def get_codename(self): return "stretch" if v.startswith("10."): return "buster" - return "" + # Debian 11 起版本号不再带小数点, 直接取 lsb/os-release 里的代号 + codename = (self.lsb_info[2] or "").split(" ")[0].lower() + if codename: + return codename + return {"11": "bullseye", "12": "bookworm", "13": "trixie"}.get( + v.split(".")[0], "" + ) class FdoDetect(OsDetector): """ @@ -985,12 +1012,23 @@ def __init__( executable="/bin/sh", ) -> None: super().__init__(Task.TASK_TYPE_CMD) - self.command = command + self.command = CmdTask.strip_sudo_if_needed(command) self.timeout = timeout self.os_command = os_command self.cwd = path self.executable = executable + @staticmethod + def strip_sudo_if_needed(command): + """ + Docker 容器里一般是 root 且没有装 sudo, 这时去掉命令里的 sudo 前缀, 否则一律 command not found + """ + if not isinstance(command, str) or os.geteuid() != 0: + return command + if shutil.which("sudo"): + return command + return re.sub(r"(^|[|;&(]\s*)sudo\s+(?:-E\s+)?", r"\1", command) + def getlog(self, callback=None): stdout_line = "" for line in iter(self.sub.stdout.readline, "b"): @@ -1419,8 +1457,8 @@ def check_result(result, patterns: list): line = str(line) # 确保 line 是字符串 # 对每个 pattern 进行检查 for pattern in patterns: - # 如果找到一个匹配 - if len(re.findall(pattern, line)) == 1: + # 如果找到匹配(输出被拼成一整段, 同一错误往往出现多次, 不能要求恰好一次) + if re.search(pattern, line): return True # 返回 True 表示匹配成功 # 如果所有行和所有模式都未匹配,返回 False return False From df42f04645b31ed35dc769c8c46aec0f9ec168aa Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 22:02:52 -0700 Subject: [PATCH 05/45] =?UTF-8?q?fix(install):=20=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=B8=8B=E8=BD=BD=20install.py=20=E4=B8=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=AF=81=E4=B9=A6=EF=BC=8C=E4=B8=8E=E5=85=B6?= =?UTF-8?q?=E5=AE=83=E4=B8=8B=E8=BD=BD=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务器把 http 301 到 https 时,老系统(证书库过旧)和树莓派/Jetson 等开机时钟 不准的设备在第一步下载 install.py 就会因证书校验失败而出不来菜单。 install.py 下载其余脚本本来就带 --no-check-certificate,这里保持一致。 Co-Authored-By: Claude Opus 5.5 --- install | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/install b/install index 25d8f21..4275c64 100644 --- a/install +++ b/install @@ -9,7 +9,8 @@ fi # 结果要么没有菜单直接退出, 要么跑的是旧脚本, 所以每次先清掉 $SUDO rm -rf /tmp/fishinstall/ mkdir -p /tmp/fishinstall/tools/translation/assets -wget -q $FISHROS_URL/install.py -O /tmp/fishinstall/install.py +# 与 install.py 里其它下载保持一致不校验证书: 老系统证书库过旧、树莓派等开机时钟不准时, 服务器跳转到 https 也能下载 +wget -q --no-check-certificate $FISHROS_URL/install.py -O /tmp/fishinstall/install.py source /etc/profile if [ -s /tmp/fishinstall/install.py ]; then @@ -17,7 +18,7 @@ if [ -s /tmp/fishinstall/install.py ]; then $SUDO python3 /tmp/fishinstall/install.py else echo "下载一键安装脚本失败: ${FISHROS_URL}install.py" - echo "请检查网络后重试; 如果提示证书相关错误, 请先执行 sudo apt install -y ca-certificates" + echo "请检查网络后重试(能否打开 ${FISHROS_URL}install.py)" fi $SUDO rm -rf /tmp/fishinstall/ From 4f7ac9452b4c530d6f31920358d23860510c613a Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 20:13:20 -0700 Subject: [PATCH 06/45] =?UTF-8?q?feat(ros):=20=E6=96=B0=E5=A2=9E=E5=85=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AE=89=E8=A3=85=E6=A8=A1=E5=BC=8F(?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=2021)=EF=BC=8C=E6=8D=A2=E6=BA=90=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社区反馈一键安装选项太多:换不换源、怎么清理、怎么测速、选哪个镜像、 装哪个版本、桌面版还是基础版,新手一路要做 6~7 次选择,选错一步就失败。 新增菜单 21「一键安装(新手推荐):全自动安装ROS」,只需确认一次: - 自动识别系统/架构,列出将要执行的步骤和要装的版本,确认后全程不再提问 - 系统源:自动测速选最快镜像;第三方源先保留,apt update 失败时才挪走重试 - ROS 源:国内按测速顺序尝试各镜像(过期/失败自动换下一个),国外直接用官方源 - 版本:按系统挑推荐版本(18.04 melodic、20.04 foxy、22.04 humble、 24.04 jazzy、26.04 lyrical),推荐版本不可用时自动退到可用版本 - 磁盘不足 6GB 自动改装基础版,不足 2GB 直接提示扩容 - 装完给出小乌龟等验证命令 - 选「我要自己选」回到原来的自定义流程 菜单 1 改名为「高手自定义」,流程不变,已有的 fish_install.yaml / Dockerfile 自动化配置不受影响。21 放在 ROS 分类第一位(菜单改为按 install.py 书写顺序展示)。 其他改动: - 换源清理第三方源从 rm -rf 改为改名备份(*.fishros-bak-时间),可手动恢复 - tool_install_ros 拆出 install_ros_packages 供两种模式复用;add_source 支持 auto - 引导脚本用 `sudo env FISHROS_URL=...` 启动 install.py。原来 sudo 会清掉该变量, 自建服务器测试时只有 install.py 来自测试服务器,其余工具仍从官方地址下载旧版 - 检测到不在国内时只有使用官方镜像站才切到 GitHub,自建服务器地址保持不变 - README 补充新手入口和自建服务器测试方法;CI 增加 focal/jammy 全自动用例 Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++- install | 3 +- install.py | 12 ++- tests/fish_install_test.yaml | 14 +++ tools/base.py | 4 +- tools/tool_config_system_source.py | 59 +++++++++++-- tools/tool_install_ros.py | 64 +++++++++++--- tools/tool_install_ros_auto.py | 132 +++++++++++++++++++++++++++++ 8 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 tools/tool_install_ros_auto.py diff --git a/README.md b/README.md index 57907b0..477dfad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ 已支持工具列表: -- 一键安装:ROS(支持ROS和ROS2,树莓派Jetson) [贡献@小鱼](https://github.com/fishros) +- 一键安装(新手推荐):全自动安装ROS,自动识别系统/换源/选版本,只需确认一次 [贡献@小鱼](https://github.com/fishros) +- 一键安装(高手自定义):ROS(支持ROS和ROS2,树莓派Jetson,自选镜像源和版本) [贡献@小鱼](https://github.com/fishros) - 一键安装:VsCode(支持amd64和arm64) [贡献@小鱼](https://github.com/fishros) - 一键安装:github桌面版(小鱼常用的github客户端) [贡献@小鱼](https://github.com/fishros) - 一键安装:nodejs开发环境(通过nodejs可以预览小鱼官网噢) [贡献@小鱼](https://github.com/fishros) @@ -24,6 +25,22 @@ source <(wget -qO- http://fishros.com/install) ``` +第一次装 ROS、不知道怎么选的,在菜单里输入 `21`(全自动安装ROS),确认后脚本会自动识别系统、 +测速换源、挑选可用的镜像和对应的 ROS 版本,中途不用再做选择。想自己指定镜像源和版本的,选 `1`。 + +### 用自己的服务器测试新版本 + +把仓库文件放到任意 HTTP 服务器上,把 `install` 第一行改成你的地址,然后在测试机上运行即可, +后续所有工具脚本都会从这个地址下载: + +``` +# 服务器上(仓库目录内) +sed -i "1s#.*#export FISHROS_URL=http://<服务器IP>:8080/#" install +python3 -m http.server 8080 +# 测试机上 +wget http://<服务器IP>:8080/install -O fishros && . fishros +``` + ## 如何自动选择(Dockerfile中使用) 目前一键安装支持从配置文件自动输入选项,你需要手动运行一次一键安装,使用完毕后会自动产生 `/tmp/fish_install.yaml`。 diff --git a/install b/install index 4275c64..a513e12 100644 --- a/install +++ b/install @@ -15,7 +15,8 @@ source /etc/profile if [ -s /tmp/fishinstall/install.py ]; then $SUDO apt install python3-distro python3-yaml -y - $SUDO python3 /tmp/fishinstall/install.py + # sudo 默认会清掉环境变量, 显式把 FISHROS_URL 传进去, 否则后续工具一律从默认地址下载(自建服务器测试时会拿到旧版) + $SUDO env FISHROS_URL="$FISHROS_URL" python3 /tmp/fishinstall/install.py else echo "下载一键安装脚本失败: ${FISHROS_URL}install.py" echo "请检查网络后重试(能否打开 ${FISHROS_URL}install.py)" diff --git a/install.py b/install.py index 4108a8a..9e821ed 100644 --- a/install.py +++ b/install.py @@ -20,8 +20,15 @@ tools = { + # 21 放在最前面, 菜单里排在 ROS 相关第一个; ID 1 保持原流程不变, 兼容已有的 fish_install.yaml 自动化配置 + 21: { + "tip": "一键安装(新手推荐):全自动安装ROS,自动识别系统/换源/选版本,全程不用选", + "type": INSTALL_ROS, + "tool": "tools/tool_install_ros_auto.py", + "dep": [1, 4, 5], + }, 1: { - "tip": "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)", + "tip": "一键安装(高手自定义):ROS(支持ROS/ROS2,树莓派Jetson,自选镜像源和版本)", "type": INSTALL_ROS, "tool": "tools/tool_install_ros.py", "dep": [4, 5], @@ -198,7 +205,8 @@ def main(): ).run() PrintUtils.print_success(tr.tr("已为您切换语言至当前所在国家语言:") + tr.lang) - if tr.country != "CN": + # 只有使用官方镜像站时才切到 GitHub; 自建服务器(FISHROS_URL 指向别处)测试时保持原地址 + if tr.country != "CN" and "mirror.fishros.com" in url_prefix: PrintUtils.print_success( tr.tr( "检测到当前不在CN,切换服务地址为:https://raw.githubusercontent.com/fishros/install/master/" diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index ce5f1cf..725192e 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -113,3 +113,17 @@ - { choose: 1, desc: "ROS国内镜像源(优先第一个)" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } + +################################################################################################################################################################## +# 全自动模式(新手推荐, 菜单 21): 只需确认一次, 系统源/ROS源/版本全部自动 +- name: "Install_ROS_focal_auto" + target_os_version: "focal" + chooses: + - { choose: 21, desc: "一键安装(新手推荐):全自动安装ROS" } + - { choose: 1, desc: "开始全自动安装" } + +- name: "Install_ROS_jammy_auto" + target_os_version: "jammy" + chooses: + - { choose: 21, desc: "一键安装(新手推荐):全自动安装ROS" } + - { choose: 1, desc: "开始全自动安装" } diff --git a/tools/base.py b/tools/base.py index 6ec0e66..53fee87 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1226,8 +1226,8 @@ def __choose(data, tips, array, categories): # 打印不同类型工具的分类结果 for tool_type, tools_list in dic.items(): PrintUtils.print_delay("{}:".format(tr.tr(categories[tool_type])), 0.005) - sortkeys = sorted(tools_list.keys()) - for tool_id in sortkeys: + # 按 install.py 里的书写顺序展示, 方便把推荐项排在前面 + for tool_id in tools_list.keys(): PrintUtils.print_delay( " [{}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])), 0.005, diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index 9782495..1fce2e0 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +import time from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask from .base import osversion @@ -90,21 +91,55 @@ def add_ros_source(self): tool.add_key() tool.add_source() - def clean_old_source(self): - dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} - code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() - # 删除之前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起删掉, 就判断不出来了 + def backup_path(self, path): + """把要清理的源文件/目录改名备份, 而不是直接删除, 出问题可以手动恢复""" + if not os.path.exists(path): + return None + backup = '{}.fishros-bak-{}'.format(path, time.strftime('%Y%m%d%H%M%S')) + CmdTask('sudo mv {} {}'.format(path, backup)).run() + return backup + + def backup_third_party_sources(self): + """ + 把 sources.list.d 里的第三方源(除系统自己的 ubuntu.sources 外)挪到备份目录. + 返回备份目录, 没有第三方源时返回 None + """ + src_dir = '/etc/apt/sources.list.d' + if not os.path.isdir(src_dir): + return None + files = [f for f in os.listdir(src_dir) if f != 'ubuntu.sources'] + if not files: + return None + backup = '{}.fishros-bak-{}'.format(src_dir, time.strftime('%Y%m%d%H%M%S')) + CmdTask('sudo mkdir -p {}'.format(backup)).run() + for f in files: + CmdTask('sudo mv {}/{} {}/'.format(src_dir, f, backup)).run() + return backup + + def clean_old_source(self, auto=False): + if auto: + # 全自动模式: 只换系统源, 第三方源先保留, apt update 失败时再挪走(见 change_sys_source) + code = 1 + else: + dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} + code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() + # 清理前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起挪走, 就判断不出来了 self.use_deb822 = os.path.exists(ubuntu_deb822_file) backup = '/etc/apt/ubuntu.sources.fishros.bak' if self.use_deb822 and not os.path.exists(backup): CmdTask('sudo cp {} {}'.format(ubuntu_deb822_file, backup)).run() - FileUtils.delete('/etc/apt/sources.list') + self.backup_path('/etc/apt/sources.list') if code==2: - print("删除一个资源文件") - FileUtils.delete('/etc/apt/sources.list.d') + backup_dir = self.backup_third_party_sources() + if backup_dir: + PrintUtils.print_info('第三方源已备份到 {}, 如需恢复可把里面的文件挪回 /etc/apt/sources.list.d/'.format(backup_dir)) # fix add source failed before config system source CmdTask('sudo mkdir -p /etc/apt/sources.list.d').run() + if auto: + self.source_method_code = 1 + return + # 添加选择源的方式 PrintUtils.print_info("源选择方式说明:") PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") @@ -235,8 +270,8 @@ def replace_source(self,failed_sources=[]): return source - def change_sys_source(self): - self.clean_old_source() + def change_sys_source(self, auto=False): + self.clean_old_source(auto=auto) failed_sources = [] result = (-1, [], []) @@ -244,6 +279,12 @@ def change_sys_source(self): if source: PrintUtils.print_delay("替换镜像源完成,尝试进行更新....") result = CmdTask('sudo apt update',100).run() + # 全自动模式下保留了第三方源, 更新失败多半是它们的问题(密钥过期/源失效), 先挪走再试 + if result[0] != 0 and auto: + backup_dir = self.backup_third_party_sources() + if backup_dir: + PrintUtils.print_warn('更新失败, 已把可能有问题的第三方源备份到 {} 后重试'.format(backup_dir)) + result = CmdTask('sudo apt update',100).run() if result[0] != 0: if getattr(self, 'source_method_code', 1) == 2: PrintUtils.print_warn("您选择的源更新失败,请重新选择其他源") diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 0dc1eb3..6b8c1df 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -620,21 +620,46 @@ def find_stale_mirrors(self, ros_pkg): ) return stale - def add_source(self): + def auto_mirror_order(self, codename): + """ + 全自动模式下的镜像尝试顺序: 国外直接用官方源; 国内按测速结果排列国内镜像, 官方源兜底 + """ + names = [ + name + for name in ("ustc", "tsinghua", "huawei", "mirrorz", "aliyun") + if name in ros_dist_dic.get(codename, ()) or name in ros2_dist_dic.get(codename, ()) + ] + if not names or (tr is not None and not tr.isCN()): + return ["packages.ros"] + names + url_to_name = {} + for name in names: + url = ros_mirror_dic[name].get("ROS2") or ros_mirror_dic[name].get("ROS1") + url_to_name[url] = name + PrintUtils.print_info("正在为您测速挑选最快的ROS镜像源...") + ordered = [url_to_name[url] for url in AptUtils.get_fast_url(list(url_to_name.keys()))] + return ordered + ["packages.ros"] + + def add_source(self, auto=False): """ 检查并添加ROS系统源 + auto: 全自动模式, 不询问用户, 按测速顺序自动尝试, 失败自动换下一个 """ arch = AptUtils.getArch() if arch == None: return False codename = osversion.get_codename() - # 让用户选择镜像源 - selected_mirror = self.select_mirror() - PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) + if auto: + candidates = self.auto_mirror_order(codename) + selected_mirror = candidates[0] + PrintUtils.print_info("自动选择的ROS镜像源: {}".format(selected_mirror)) + else: + # 让用户选择镜像源 + selected_mirror = self.select_mirror() + PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) excluded = {"ROS1": set(), "ROS2": set()} - for attempt in range(6): + for attempt in range(8): mirrors = self.get_mirror_by_code( codename, arch=arch, first_choose=selected_mirror, excluded=excluded ) @@ -647,19 +672,29 @@ def add_source(self): if ros_pkg: stale = self.find_stale_mirrors(ros_pkg) if not stale: - PrintUtils.print_success( - "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" - ) + if auto: + PrintUtils.print_success("ROS软件源添加成功") + else: + PrintUtils.print_success( + "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" + ) return True for version, name in stale.items(): excluded[version].add(name) PrintUtils.print_warn("自动切换到下一个可用的镜像源重试...") continue - # apt update 失败或搜不到 ROS 包, 让用户换一个镜像源再试 - PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") + # apt update 失败或搜不到 ROS 包, 换一个镜像源再试 for version in excluded.keys(): excluded[version].add(selected_mirror) + if auto: + remaining = [m for m in candidates if m not in excluded["ROS2"]] + if not remaining: + break + selected_mirror = remaining[0] + PrintUtils.print_warn("换源后更新失败, 自动换用镜像源: {}".format(selected_mirror)) + continue + PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") selected_mirror = self.select_mirror() PrintUtils.print_info("您重新选择的镜像源: {}".format(selected_mirror)) for version in excluded.keys(): @@ -828,13 +863,18 @@ def choose_and_install_ros(self): PrintUtils.print_error("你选择退出。。。。") return False + return self.install_ros_packages(ros_name[rosname], code, dic_base) + + def install_ros_packages(self, install_version, code, dic_base): + """ + 安装指定版本的ROS, 自定义模式和全自动模式共用 + install_version: 版本名, 如 humble; code: 1 桌面版, 2 基础版; dic_base: 版本名 -> ros-base 包名 + """ install_tool = "aptitude" install_tool_apt = "apt" if osversion.get_version() == "16.04": install_tool = "apt" - install_version = ros_name[rosname] - if install_tool == "aptitude": AptUtils.install_pkg("aptitude") diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py new file mode 100644 index 0000000..9fb6e4b --- /dev/null +++ b/tools/tool_install_ros_auto.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +import shutil +from .base import BaseTool +from .base import PrintUtils, AptUtils, ChooseTask +from .base import osversion +from .base import run_tool_file +from .tool_install_ros import RosVersions + +# 每个系统推荐给新手的 ROS 版本(按优先级). 统一推荐该系统对应的 ROS2 长期支持版, +# 18.04 上的 ROS2 早已停止维护且国内镜像不全, 推荐 ROS1 melodic +recommend_ros = { + "bionic": ["melodic"], + "focal": ["foxy", "noetic"], + "jammy": ["humble"], + "noble": ["jazzy"], + "resolute": ["lyrical"], +} +# 推荐表里没有的系统(如 Debian), 按这个顺序挑第一个能装的 +fallback_ros = ["lyrical", "jazzy", "humble", "foxy", "kilted", "iron", "galactic", "noetic", "melodic", "rolling"] + +# 桌面版装完约占 3~4GB, 剩余空间不足时自动改装基础版 +min_free_gb_for_desktop = 6 +min_free_gb = 2 + + +class Tool(BaseTool): + def __init__(self): + self.name = "全自动安装ROS(新手推荐)" + self.type = BaseTool.TYPE_INSTALL + self.author = "小鱼" + + @staticmethod + def pick_version(codename, available): + for name in recommend_ros.get(codename, []) + fallback_ros: + if name in available: + return name + return sorted(available)[0] if available else None + + @staticmethod + def free_gb(): + try: + return shutil.disk_usage("/").free / 1024.0 / 1024 / 1024 + except Exception: + return 100 + + def show_plan(self, expect_version, desktop): + ros_name = "适合本系统的ROS版本(添加软件源后自动确定)" + if expect_version: + info = RosVersions.get_version_string(expect_version) + ros_name = "{} {}".format(info, "桌面版" if desktop else "基础版") + PrintUtils.print_success("=" * 60) + PrintUtils.print_success( + "检测到您的系统: {} {} ({}), 架构 {}".format( + osversion.get_name().capitalize(), + osversion.get_version(), + osversion.get_codename(), + AptUtils.getArch(), + ) + ) + PrintUtils.print_info("全自动模式将依次完成以下步骤, 中途不需要再做选择:") + PrintUtils.print_info(" 1. 更换系统软件源: 自动测速, 选最快的镜像(原配置会备份)") + PrintUtils.print_info(" 2. 添加ROS软件源: 自动测速选镜像, 遇到坏掉的镜像自动换下一个") + PrintUtils.print_info(" 3. 安装 {}".format(ros_name)) + PrintUtils.print_info(" 4. 配置环境变量, 新开终端就能直接使用ROS") + if desktop: + PrintUtils.print_info("预计下载 0.5~1GB, 占用磁盘 3~4GB, 用时 10~40 分钟(取决于网速)") + PrintUtils.print_success("=" * 60) + + def print_next_steps(self, version, desktop): + ros1 = RosVersions.get_version(version).version == "ROS1" + PrintUtils.print_success("接下来这样验证ROS是否装好(请先新开一个终端):") + if ros1: + PrintUtils.print_info(" 终端1输入: roscore") + if desktop: + PrintUtils.print_info(" 终端2输入: rosrun turtlesim turtlesim_node (会弹出一只小乌龟)") + PrintUtils.print_info(" 终端3输入: rosrun turtlesim turtle_teleop_key (在这个终端按方向键控制小乌龟)") + elif desktop: + PrintUtils.print_info(" 终端1输入: ros2 run turtlesim turtlesim_node (会弹出一只小乌龟)") + PrintUtils.print_info(" 终端2输入: ros2 run turtlesim turtle_teleop_key (在这个终端按方向键控制小乌龟)") + else: + PrintUtils.print_info(" 输入: ros2 topic list (能列出 /rosout 等话题就说明正常)") + + def run(self): + self.ros_tool = run_tool_file("tools.tool_install_ros", authorun=False) + if not self.ros_tool.support_install(): + return False + + free = self.free_gb() + if free < min_free_gb: + PrintUtils.print_error("磁盘剩余空间只有 {:.1f}GB, 不够安装ROS, 请先清理磁盘或给虚拟机扩容".format(free)) + return False + desktop = free >= min_free_gb_for_desktop + if not desktop: + PrintUtils.print_warn("磁盘剩余空间 {:.1f}GB, 不够装桌面版, 将改为安装基础版(无图形工具)".format(free)) + + codename = osversion.get_codename() + expect = recommend_ros.get(codename, [None])[0] + self.show_plan(expect, desktop) + code, _ = ChooseTask( + {1: "开始全自动安装(推荐)", 2: "我要自己选(进入高手自定义模式)"}, + "请确认:", + ).run() + if code == 0: + return False + if code == 2: + return self.ros_tool.run() + + source_tool = run_tool_file("tools.tool_config_system_source", authorun=False) + source_tool.change_sys_source(auto=True) + + self.ros_tool.add_key() + if not self.ros_tool.add_source(auto=True): + return False + + dic_base = AptUtils.search_package("ros-base", "ros-[A-Za-z]+-ros-base", "ros-", "-base") + if not dic_base: + PrintUtils.print_error("软件源里没有找到可安装的ROS, 请携带 /tmp/fishros_install.log 到鱼香社区反馈") + return False + version = self.pick_version(codename, list(dic_base.keys())) + if expect and version != expect: + PrintUtils.print_warn("推荐的 {} 当前无法安装, 改为安装 {}".format(expect, version)) + PrintUtils.print_success( + "开始安装 {} {}, 请耐心等待...".format( + RosVersions.get_version_string(version), "桌面版" if desktop else "基础版" + ) + ) + + self.ros_tool.install_ros_packages(version, 1 if desktop else 2, dic_base) + self.ros_tool.config_env_and_tip(version) + if self.ros_tool.install_success(version): + self.print_next_steps(version, desktop) + return True From 2bdc73373e7aadd5325eae377ba550495d808488 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 20:47:43 -0700 Subject: [PATCH 07/45] =?UTF-8?q?fix(base):=20CmdTask=20=E8=AF=BB=20stdout?= =?UTF-8?q?/stderr=20=E4=BA=92=E7=9B=B8=E7=AD=89=E5=BE=85=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getlog 原来先把 stdout 读到进程结束,再去读 stderr。子进程往 stderr 写满管道缓冲(64KB)后会阻塞在 write 上不退出,stdout 也就一直不结束, 两边互相等待,表现为一键安装「卡住不动」。 容器实测:apt 一次装 ros-foxy-desktop(958 个包)下载完成后卡住 35 分钟, dpkg 始终没启动,python 读线程停在 anon_pipe_read。最小复现:子进程写 20000 行到 stderr 再打印一行到 stdout,旧代码卡死(timeout 杀掉), 新代码正常返回并拿到全部 stderr。 改为单独起线程同时读 stderr,读完 stdout 后 wait 取返回码。 Co-Authored-By: Claude Opus 5.5 --- tools/base.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tools/base.py b/tools/base.py index 53fee87..19984af 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1030,22 +1030,24 @@ def strip_sudo_if_needed(command): return re.sub(r"(^|[|;&(]\s*)sudo\s+(?:-E\s+)?", r"\1", command) def getlog(self, callback=None): - stdout_line = "" - for line in iter(self.sub.stdout.readline, "b"): + # stdout 和 stderr 要同时读: 原来读完 stdout 才读 stderr, 子进程往 stderr 写满管道缓冲(64KB)后 + # 会阻塞不退出, stdout 也就一直不结束, 两边互相等待. apt 一次装近千个包时实测会这样卡死 + def read_stderr(): + for line in iter(self.sub.stderr.readline, ""): + line = line.rstrip() + if callback and line: + callback(line, "err") + + err_thread = threading.Thread(target=read_stderr) + err_thread.daemon = True + err_thread.start() + + for line in iter(self.sub.stdout.readline, ""): line = line.rstrip() # .decode('utf8', errors="ignore") if callback and line: callback(line, "out") - if subprocess.Popen.poll(self.sub) is not None: - if line == "": - break - - for line in iter(self.sub.stderr.readline, "b"): - line = line.rstrip() # .decode('utf8', errors="ignore") - if callback and line: - callback(line, "err") - if subprocess.Popen.poll(self.sub) is not None: - if line == "": - break + self.sub.wait() + err_thread.join(timeout=5) def getlogs(self): out = [] From 4b6bd6befe25d7fbb6b7978a809bc66ccf0225a2 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 20:47:43 -0700 Subject: [PATCH 08/45] =?UTF-8?q?feat(ros):=20=E5=85=A8=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=B3=BB=E7=BB=9F=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E4=B8=AD=E9=80=94=E6=96=AD=E8=BF=9E=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8D=A2=E9=95=9C=E5=83=8F=E7=BB=AD=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测速只看首个请求的延迟,选出的系统镜像在连续下载近千个包时可能限流或 断连(实测中科大多次 Unable to connect)。全自动模式下如果安装失败且报错 里是当前系统镜像下载失败,自动换下一个最快的镜像、apt update 后继续安装 (已下载的包会复用),最多换两次。另外全自动模式下不再提示「使用[1]安装」。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_system_source.py | 19 +++++++++++++++++++ tools/tool_install_ros.py | 2 ++ tools/tool_install_ros_auto.py | 22 +++++++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index 1fce2e0..b494e72 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -299,11 +299,30 @@ def change_sys_source(self, auto=False): else: PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") + self.current_source = source if result[0] == 0 else None + self.failed_sources = failed_sources if result[0]==0: PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") PrintUtils.print_info(result[1]) PrintUtils.print_success("镜像更新完成.....") + def switch_to_next_source(self): + """ + 全自动模式: 当前系统镜像在安装中途下载失败(限流/断连)时, 换下一个最快的镜像并更新索引. + 返回新镜像地址, 没有可用镜像时返回 None + """ + if not getattr(self, 'current_source', None): + return None + self.failed_sources.append(self.current_source) + source = self.replace_source(self.failed_sources) + while source: + if CmdTask('sudo apt update', 100).run()[0] == 0: + self.current_source = source + return source + self.failed_sources.append(source) + source = self.replace_source(self.failed_sources) + return None + def run(self): # 正式的运行 self.change_sys_source() diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 6b8c1df..65ca412 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -1018,6 +1018,8 @@ def install_ros_packages(self, install_version, code, dic_base): "安装过程中出现 404, 说明当前ROS镜像源缺包, 请重新运行一键安装并换一个ROS镜像源(阿里云或ROS官方源)" ) + self.last_install_result = cmd_result + # 安装额外的依赖 RosVersions.install_depend(install_version) diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index 9fb6e4b..a8cc72a 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import shutil +from urllib.parse import urlparse from .base import BaseTool from .base import PrintUtils, AptUtils, ChooseTask from .base import osversion @@ -43,6 +44,16 @@ def free_gb(): except Exception: return 100 + def system_mirror_failed(self, source_tool): + """最后一次安装的报错里是否有当前系统镜像的下载失败""" + source = getattr(source_tool, "current_source", None) + result = getattr(self.ros_tool, "last_install_result", None) + if not source or not result or len(result) < 3: + return False + host = urlparse(source).netloc + output = " ".join(str(line) for line in list(result[1]) + list(result[2])) + return host in output and ("Failed to fetch" in output or "无法下载" in output) + def show_plan(self, expect_version, desktop): ros_name = "适合本系统的ROS版本(添加软件源后自动确定)" if expect_version: @@ -125,7 +136,16 @@ def run(self): ) ) - self.ros_tool.install_ros_packages(version, 1 if desktop else 2, dic_base) + install_code = 1 if desktop else 2 + self.ros_tool.install_ros_packages(version, install_code, dic_base) + for retry in range(2): + if self.ros_tool.install_success(version) or not self.system_mirror_failed(source_tool): + break + new_source = source_tool.switch_to_next_source() + if not new_source: + break + PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) + self.ros_tool.install_ros_packages(version, install_code, dic_base) self.ros_tool.config_env_and_tip(version) if self.ros_tool.install_success(version): self.print_next_steps(version, desktop) From 8bf432f3e5abbd3f40b17e76f969bcdb448c0a55 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 21:02:15 -0700 Subject: [PATCH 09/45] =?UTF-8?q?fix(ros):=20=E5=AF=BC=E5=85=A5=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=20gpg=20=E5=92=8C?= =?UTF-8?q?=20curl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容器实测:系统镜像中途断连 → gnupg2 装不上 → gpg 不存在(code 127) → 公钥导入失败 → 所有 ROS 源都 NO_PUBKEY,换哪个镜像都没用。 - gpg 不可用时把 ASCII 公钥直接放到 /etc/apt/trusted.gpg.d/ros.asc (apt>=1.4,即 Ubuntu 18.04 起支持),最后再退到 apt-key - Ubuntu 22.04/24.04 桌面版默认没有 curl:下载公钥和过期镜像检测在没有 curl 时改用 wget(用户就是用 wget 下载的一键安装,一定有) 在没有 curl/gpg、且禁止安装二者的 focal 容器中验证:公钥经 wget 下载后以 .asc 导入、apt update 通过;wget --spider 检出清华 ROS2 包 404 并自动换到阿里云。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_ros.py | 48 +++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 65ca412..83ec2b8 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -197,6 +197,8 @@ def get_desktop_version(name): # ROS 官方 GPG 公钥完整指纹, 密钥服务器兜底时使用(短指纹容易撞) ros_key_fingerprint = "C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654" ros_keyring = "/etc/apt/trusted.gpg.d/ros.gpg" +# 没有 gpg 时的退路: apt>=1.4(Ubuntu 18.04+) 能直接识别 trusted.gpg.d 下 ASCII 格式的 .asc 公钥 +ros_keyring_asc = "/etc/apt/trusted.gpg.d/ros.asc" ros_mirror_dic = { "tsinghua": { @@ -583,6 +585,26 @@ def write_ros_source(self, arch, codename, mirrors): FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + @staticmethod + def has_command(name): + return CmdTask("command -v {}".format(name), 5).run()[0] == 0 + + def http_status(self, url): + """ + 返回 url 跟随跳转后的 HTTP 状态码, 取不到返回空串. + Ubuntu 22.04/24.04 桌面版默认没装 curl, 系统源坏了又装不上时用 wget 兜底 + """ + if self.has_command("curl"): + check = CmdTask( + "curl -sIL -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(url), 20 + ).run() + return "".join(check[1]).strip() + check = CmdTask( + "wget --spider -S --timeout=15 --tries=1 '{}' 2>&1 | grep 'HTTP/'".format(url), 20 + ).run() + codes = re.findall(r"HTTP/[\d.]+ (\d+)", " ".join(check[1])) + return codes[-1] if codes else "" + def find_stale_mirrors(self, ros_pkg): """ 国内镜像有时只剩索引、包文件已被删除, apt update 正常, 一安装就满屏 404. @@ -600,11 +622,7 @@ def find_stale_mirrors(self, ros_pkg): if not uri: continue uri = uri[0] - check = CmdTask( - "curl -sIL -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(uri), - 20, - ).run() - http_code = "".join(check[1]).strip() + http_code = self.http_status(uri) # 只认明确的 403/404/410, 网络超时(000)等不算, 避免误判 if http_code not in ("403", "404", "410"): continue @@ -711,12 +729,15 @@ def download_ros_key(self): gitee raw 现在会 302 跳到 raw.giteeusercontent.com, curl 必须带 -L, 否则拿到的是一段 HTML """ key_file = "/tmp/fishros_ros.asc" + # wget 一定有(用户就是用它下载的一键安装), curl 不一定 + if self.has_command("curl"): + download = "curl -fsSL --retry 2 --max-time 30 {url} -o {out}" + else: + download = "wget -q --tries=2 --timeout=30 -O {out} {url}" PrintUtils.print_success(tr.tr("正在挑选最快的密钥服务:{}").format(key_urls)) for key_url in AptUtils.get_fast_url(key_urls): CmdTask("rm -f {}".format(key_file), 3).run() - result = CmdTask( - "curl -fsSL --retry 2 --max-time 30 {} -o {}".format(key_url, key_file), 40 - ).run() + result = CmdTask(download.format(url=key_url, out=key_file), 40).run() if result[0] != 0: continue check = CmdTask( @@ -749,7 +770,16 @@ def add_key(self): ), 20, ).run() - if cmd_result[0] != 0 and CmdTask("command -v apt-key", 10).run()[0] == 0: + if cmd_result[0] != 0: + # 没有 gpg(精简系统/容器, 或系统源坏了装不上 gnupg)时直接放 .asc 公钥 + PrintUtils.print_info("gpg 不可用, 改为直接导入 ASCII 格式公钥") + cmd_result = CmdTask( + "sudo rm -f {0} && sudo cp {1} {2} && sudo chmod 644 {2}".format( + ros_keyring, key_file, ros_keyring_asc + ), + 10, + ).run() + if cmd_result[0] != 0 and self.has_command("apt-key"): cmd_result = CmdTask("sudo apt-key add {}".format(key_file), 20).run() if cmd_result[0] != 0: From 72872cb2fc2406f99ff839685489c5f8ea3dc290 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Sun, 11 Oct 2026 21:47:21 -0700 Subject: [PATCH 10/45] =?UTF-8?q?fix(source):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8D=A2=E7=B3=BB=E7=BB=9F=E9=95=9C=E5=83=8F=E6=97=B6=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E5=90=8C=E4=B8=80=E5=8F=B0=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E7=9A=84=20http/https=20=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 22.04 容器实测:中科大 http 下载中途断连后,"换下一个最快镜像"选中的是 https://mirrors.ustc.edu.cn,只是换了协议、还是同一台服务器。这次碰巧能用, 但服务器真不可用时等于没换。改为把失败地址的 http/https 两种写法一起排除。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_system_source.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index b494e72..d6df493 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -313,16 +313,21 @@ def switch_to_next_source(self): """ if not getattr(self, 'current_source', None): return None - self.failed_sources.append(self.current_source) + self.exclude_server(self.current_source) source = self.replace_source(self.failed_sources) while source: if CmdTask('sudo apt update', 100).run()[0] == 0: self.current_source = source return source - self.failed_sources.append(source) + self.exclude_server(source) source = self.replace_source(self.failed_sources) return None + def exclude_server(self, source): + """同一台服务器的 http/https 地址一起排除, 否则"换镜像"可能只是换了个协议""" + address = source.split('://', 1)[-1] + self.failed_sources.extend(['http://' + address, 'https://' + address]) + def run(self): # 正式的运行 self.change_sys_source() From f5823baf20779a5dae3600fba427654cab30de56 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 00:37:51 -0700 Subject: [PATCH 11/45] =?UTF-8?q?refactor(menu):=20=E4=B8=BB=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=8C=89=E7=94=A8=E9=80=94=E9=87=8D=E6=96=B0=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=8E=92=E5=BA=8F=EF=BC=8C=E7=BC=96=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的分组比较乱:NodeJs、科学上网工具归在「AI板块」,MicroROS 开发环境 归在「常用软件」,同组内编号跳来跳去。 - 分为 ROS安装 / ROS开发工具 / 换源与网络 / 开发工具 / 日常软件 五组, 组内按常用程度排列 - 菜单编号全部保持不变(fish_install.yaml、Dockerfile 按编号自动选择), 已核对新旧 tools 字典的 编号→工具文件/依赖 完全一致 - 统一菜单文字格式(全角/半角冒号、PlateformIO 拼写),Cartographer 注明仅支持 ROS1 - 编号右对齐显示;补齐所有分组和菜单项的英文翻译 - README 工具列表按新分组重写,补上原来缺的 11/12/13/14/15/16/17/18 Co-Authored-By: Claude Opus 5.5 --- README.md | 45 +++++--- install.py | 173 ++++++++++++++++-------------- tools/base.py | 2 +- tools/translation/assets/en_US.py | 29 ++++- 4 files changed, 147 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 477dfad..0c18b7e 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,38 @@ ## 工具列表 -已支持工具列表: - -- 一键安装(新手推荐):全自动安装ROS,自动识别系统/换源/选版本,只需确认一次 [贡献@小鱼](https://github.com/fishros) -- 一键安装(高手自定义):ROS(支持ROS和ROS2,树莓派Jetson,自选镜像源和版本) [贡献@小鱼](https://github.com/fishros) -- 一键安装:VsCode(支持amd64和arm64) [贡献@小鱼](https://github.com/fishros) -- 一键安装:github桌面版(小鱼常用的github客户端) [贡献@小鱼](https://github.com/fishros) -- 一键安装:nodejs开发环境(通过nodejs可以预览小鱼官网噢) [贡献@小鱼](https://github.com/fishros) -- 一键配置:rosdep(小鱼的rosdepc,又快又好用) [贡献@小鱼](https://github.com/fishros) -- 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择) [贡献@小鱼](https://github.com/fishros) -- 一键配置:系统源(更换系统源,支持全版本Ubuntu系统) [贡献@小鱼](https://github.com/fishros) -- 一键安装:Docker(支持amd64和arm64) [贡献@alyssa](https://github.com/alyssa1024) -- 一键安装:cartographer 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) -- 一键安装:微信客户端 [贡献@小鱼](https://github.com/fishros) +已支持工具列表(方括号里是菜单编号): + +**ROS安装** +- [21] 一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次) [贡献@小鱼](https://github.com/fishros) +- [1] 一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson) [贡献@小鱼](https://github.com/fishros) +- [11] 一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用) +- [16] 一键安装:系统自带ROS(仅供特殊情况下使用) + +**ROS开发工具** +- [4] 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择) [贡献@小鱼](https://github.com/fishros) +- [3] 一键安装:rosdepc(小鱼的国内版rosdep,又快又好用) [贡献@小鱼](https://github.com/fishros) +- [9] 一键安装:Cartographer(仅支持ROS1:Ubuntu16/18/20) 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) +- [12] 一键安装:MicroROS开发环境(PlatformIO,支持FishBot) + +**换源与网络** +- [5] 一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本) [贡献@小鱼](https://github.com/fishros) +- [13] 一键配置:Python国内源 +- [17] 一键配置:Docker代理(支持VPN+代理服务两种模式) +- [14] 一键安装:科学上网代理工具 + +**开发工具** +- [7] 一键安装:VSCode(支持amd64和arm64) [贡献@小鱼](https://github.com/fishros) +- [8] 一键安装:Docker(支持amd64和arm64) [贡献@alyssa](https://github.com/alyssa1024) +- [6] 一键安装:Node.js开发环境 [贡献@小鱼](https://github.com/fishros) +- [2] 一键安装:GitHub桌面版(小鱼常用的GitHub客户端) [贡献@小鱼](https://github.com/fishros) +- [18] 一键安装/卸载:OpenCode(AI编程助手) + +**日常软件** +- [10] 一键安装:微信客户端 [贡献@小鱼](https://github.com/fishros) +- [15] 一键安装:QQ for Linux + +> 菜单编号固定不变(自动化配置按编号选择),新增工具只会用新编号。 diff --git a/install.py b/install.py index 9e821ed..7151670 100644 --- a/install.py +++ b/install.py @@ -6,149 +6,156 @@ base_url = os.path.join(url_prefix, "tools/base.py") translator_url = os.path.join(url_prefix, "tools/translation/translator.py") -INSTALL_ROS = 0 # 安装ROS相关 -INSTALL_SOFTWARE = 1 # 安装软件 -CONFIG_TOOL = 2 # 配置相关 -INSTALL_AI = 3 # AI相关 +# 菜单分组, 显示顺序与下面 tools_type_map 的书写顺序一致 +CAT_ROS_INSTALL = 0 +CAT_ROS_TOOLS = 1 +CAT_SOURCE_NETWORK = 2 +CAT_DEV_TOOLS = 3 +CAT_DAILY = 4 tools_type_map = { - INSTALL_ROS: "ROS相关", - INSTALL_AI: "AI板块", - INSTALL_SOFTWARE: "常用软件", - CONFIG_TOOL: "配置工具", + CAT_ROS_INSTALL: "ROS安装", + CAT_ROS_TOOLS: "ROS开发工具", + CAT_SOURCE_NETWORK: "换源与网络", + CAT_DEV_TOOLS: "开发工具", + CAT_DAILY: "日常软件", } - +# 菜单编号是对外接口(fish_install.yaml / Dockerfile 里按编号自动选择), 只能新增不能改号; +# 菜单里按本字典的书写顺序展示, 所以调整顺序只需要挪位置, 不要改编号 tools = { - # 21 放在最前面, 菜单里排在 ROS 相关第一个; ID 1 保持原流程不变, 兼容已有的 fish_install.yaml 自动化配置 + # ---------------- ROS安装 ---------------- 21: { - "tip": "一键安装(新手推荐):全自动安装ROS,自动识别系统/换源/选版本,全程不用选", - "type": INSTALL_ROS, + "tip": "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)", + "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros_auto.py", "dep": [1, 4, 5], }, 1: { - "tip": "一键安装(高手自定义):ROS(支持ROS/ROS2,树莓派Jetson,自选镜像源和版本)", - "type": INSTALL_ROS, + "tip": "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)", + "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros.py", "dep": [4, 5], }, - 2: { - "tip": "一键安装:github桌面版(小鱼常用的github客户端)", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_github_desktop.py", - "dep": [], + 11: { + "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)", + "type": CAT_ROS_INSTALL, + "tool": "tools/tool_install_ros_with_docker.py", + "dep": [7, 8], }, - 3: { - "tip": "一键安装:rosdep(小鱼的rosdepc,又快又好用)", - "type": INSTALL_ROS, - "tool": "tools/tool_config_rosdep.py", - "dep": [], + 16: { + "tip": "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)", + "type": CAT_ROS_INSTALL, + "tool": "tools/tool_install_ros1_systemdefault.py", + "dep": [5], }, + # ---------------- ROS开发工具 ---------------- 4: { "tip": "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择)", - "type": INSTALL_ROS, + "type": CAT_ROS_TOOLS, "tool": "tools/tool_config_rosenv.py", "dep": [], }, - 5: { - "tip": "一键配置:系统源(更换系统源,支持全版本Ubuntu系统)", - "type": CONFIG_TOOL, - "tool": "tools/tool_config_system_source.py", - "dep": [1], - }, - 6: { - "tip": "一键安装:NodeJs环境", - "type": INSTALL_AI, - "tool": "tools/tool_install_nodejs.py", - "dep": [], - }, - 7: { - "tip": "一键安装:VsCode开发工具", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_vscode.py", - "dep": [], - }, - 8: { - "tip": "一键安装:Docker", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_docker.py", + 3: { + "tip": "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)", + "type": CAT_ROS_TOOLS, + "tool": "tools/tool_config_rosdep.py", "dep": [], }, 9: { - "tip": "一键安装:Cartographer(18 20测试通过,16未测. updateTime 20240125)", - "type": INSTALL_ROS, + "tip": "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)", + "type": CAT_ROS_TOOLS, "tool": "tools/tool_install_cartographer.py", "dep": [3], }, - 10: { - "tip": "一键安装:微信(可以在Linux上使用的微信)", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_wechat.py", - "dep": [8], - }, - 11: { - "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2)", - "type": INSTALL_ROS, - "tool": "tools/tool_install_ros_with_docker.py", - "dep": [7, 8], - }, 12: { - "tip": "一键安装:PlateformIO MicroROS开发环境(支持Fishbot)", - "type": INSTALL_SOFTWARE, + "tip": "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)", + "type": CAT_ROS_TOOLS, "tool": "tools/tool_install_micros_fishbot_env.py", "dep": [], }, + # ---------------- 换源与网络 ---------------- + 5: { + "tip": "一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本)", + "type": CAT_SOURCE_NETWORK, + "tool": "tools/tool_config_system_source.py", + "dep": [1], + }, 13: { - "tip": "一键配置:python国内源", - "type": CONFIG_TOOL, + "tip": "一键配置:Python国内源(加速pip安装)", + "type": CAT_SOURCE_NETWORK, "tool": "tools/tool_config_python_source.py", "dep": [], }, + 17: { + "tip": "一键配置:Docker代理(支持VPN+代理服务两种模式)", + "type": CAT_SOURCE_NETWORK, + "tool": "tools/tool_config_docker_proxy.py", + "dep": [], + }, 14: { "tip": "一键安装:科学上网代理工具", - "type": INSTALL_AI, + "type": CAT_SOURCE_NETWORK, "tool": "tools/tool_install_proxy_tool.py", "dep": [8], }, - 15: { - "tip": "一键安装:QQ for Linux", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_qq.py", + # ---------------- 开发工具 ---------------- + 7: { + "tip": "一键安装:VSCode(代码编辑器)", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_vscode.py", "dep": [], }, - 16: { - "tip": "一键安装:系统自带ROS (!!警告!!仅供特殊情况下使用)", - "type": INSTALL_ROS, - "tool": "tools/tool_install_ros1_systemdefault.py", - "dep": [5], + 8: { + "tip": "一键安装:Docker", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_docker.py", + "dep": [], }, - 17: { - "tip": "一键配置: Docker代理(支持VPN+代理服务两种模式)", - "type": CONFIG_TOOL, - "tool": "tools/tool_config_docker_proxy.py", + 6: { + "tip": "一键安装:Node.js环境", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_nodejs.py", + "dep": [], + }, + 2: { + "tip": "一键安装:GitHub桌面版(小鱼常用的GitHub客户端)", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_github_desktop.py", "dep": [], }, 18: { "tip": "一键安装/卸载:OpenCode(AI编程助手)", - "type": INSTALL_AI, + "type": CAT_DEV_TOOLS, "tool": "tools/tool_install_opencode.py", "dep": [6], }, # 19: { # "tip": "一键清理:OpenClaw(可选保留配置)", - # "type": INSTALL_AI, + # "type": CAT_DEV_TOOLS, # "tool": "tools/tool_config_openclaw_cleanup.py", # "dep": [], # }, # 20: { # "tip": "一键安装:OpenClaw(智能AI助手)", - # "type": INSTALL_AI, + # "type": CAT_DEV_TOOLS, # "tool": "tools/tool_install_openclaw.py", # "dep": [6], # }, + # ---------------- 日常软件 ---------------- + 10: { + "tip": "一键安装:微信(可以在Linux上使用的微信)", + "type": CAT_DAILY, + "tool": "tools/tool_install_wechat.py", + "dep": [8], + }, + 15: { + "tip": "一键安装:QQ for Linux", + "type": CAT_DAILY, + "tool": "tools/tool_install_qq.py", + "dep": [], + }, } -# # 创建用于存储不同类型工具的字典,按目录顺序展示 diff --git a/tools/base.py b/tools/base.py index 19984af..646a679 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1231,7 +1231,7 @@ def __choose(data, tips, array, categories): # 按 install.py 里的书写顺序展示, 方便把推荐项排在前面 for tool_id in tools_list.keys(): PrintUtils.print_delay( - " [{}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])), + " [{:>2}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])), 0.005, ) tool_ids.append(tool_id) diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 82fc3b8..4127b25 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -21,10 +21,30 @@ "是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~": "Cannot find proper items you want? Please contact fishros to add to the menu~", "---众多工具,等君来用---": "--- A variety of tools just waiting to be used ---", "RUN Choose Task:[请输入括号内的数字]": "RUN Choose Task:[Please enter the number in brackets]", - "ROS相关": "ROS Related", - "AI板块": "AI Section", - "常用软件": "Common Software", - "配置工具": "Configuration Tools", + "ROS安装": "Install ROS", + "ROS开发工具": "ROS Dev Tools", + "换源与网络": "Mirrors & Network", + "开发工具": "Dev Tools", + "日常软件": "Daily Apps", + "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)": "Install ROS - Auto (for beginners: detects system, mirrors and version, one confirmation)", + "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS - Custom (choose mirrors and version, ROS1/ROS2, Raspberry Pi/Jetson)", + "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", + "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", + "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择)": "Configure ROS environment (update shell setup, auto-generate version switch)", + "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)": "Install rosdepc (rosdep with China mirrors)", + "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)": "Install Cartographer (Google SLAM, ROS1 only: Ubuntu 16/18/20)", + "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)": "Install micro-ROS dev environment (PlatformIO, supports FishBot)", + "一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本)": "Configure system mirrors (Ubuntu/Debian, all versions)", + "一键配置:Python国内源(加速pip安装)": "Configure pip mirror (faster pip install in China)", + "一键配置:Docker代理(支持VPN+代理服务两种模式)": "Configure Docker proxy (VPN or proxy server)", + "一键安装:科学上网代理工具": "Install proxy tool", + "一键安装:VSCode(代码编辑器)": "Install VSCode (code editor)", + "一键安装:Docker": "Install Docker", + "一键安装:Node.js环境": "Install Node.js", + "一键安装:GitHub桌面版(小鱼常用的GitHub客户端)": "Install GitHub Desktop", + "一键安装/卸载:OpenCode(AI编程助手)": "Install/Uninstall OpenCode (AI coding assistant)", + "一键安装:微信(可以在Linux上使用的微信)": "Install WeChat for Linux", + "一键安装:QQ for Linux": "Install QQ for Linux", "请输入[]内的数字以选择:": "Type the number in [] to choose a function:", "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)": "Welcome to join the robot learning exchange QQ group (In Chinese): 438144612 (group password: 一键安装)", "鱼香小铺正式开业,最低499可入手一台能建图会导航的移动机器人,淘宝搜店:鱼香ROS 或打开链接查看:https://item.taobao.com/item.htm?id=696573635888": "Online shop is now opened! You can get a mobile robot with SLAM and navigation for as low as 499 CNY. Search for '鱼香ROS' on Taobao or visit this link: https://item.taobao.com/item.htm?id=696573635888", @@ -42,7 +62,6 @@ "请注意我,检测你在安装过程中出现依赖问题,请在稍后选择解决方案(第一个解决方案不一定可以解决问题,如再遇到可以采用下一个解决方案),即可解决": "PLEASE NOTE that I have detected dependency problems during the installation process. Please select a solution later (the first solution may not solve the problem, please try the next solution if you encounter it again) to resolve it.", "确认了解上述情况,请输入回车继续安装": "Confirm that you understand the above situation, please press Enter to continue the installation", "欢迎使用{},本工具由作者{}提供": "Welcome to use {}, this tool is brought to you by {}", - "一键安装:系统自带ROS (!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", "已为您切换语言至当前所在国家语言:": "Language switched to the current country's language:':", "检测到当前不在CN,切换服务地址为:https://raw.githubusercontent.com/fishros/install/master/": "Detected that you are not in CN, switching service address to: https://raw.githubusercontent.com/fishros/install/master/", } From 49e7f4107904cd9506354fdb9cb6dc692b2fc7ec Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:13:00 -0700 Subject: [PATCH 12/45] =?UTF-8?q?fix(rosenv):=20=E4=BF=AE=E5=A4=8D=20ROS2?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4=E5=9C=A8=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E8=B7=AF=E5=BE=84=E4=B8=8B=E7=BC=96=E8=AF=91=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8C=85=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文 Ubuntu 的 ~/桌面 等目录下 colcon build 编译 msg 包会报 "rosidl_generate_interfaces ... list index: 1 out of range" 和 "Target dependency '/xxx/build/.../msg/Xxx.idl' does not exist"。 根因:rosidl_adapter 生成的接口列表本身是对的,但 rosidl_adapt_interfaces.cmake 用 file(STRINGS ...) 读回时没加 ENCODING UTF-8,CMake 只保留 ASCII 字符串, "桌面" 被当成分隔符丢掉,一行路径被切成两段。humble/jazzy/rolling 上游都没修。 一键配置 ROS 环境(菜单 4,装完 ROS 后也会自动调用)时给已装的 ROS2 补上 ENCODING UTF-8(原文件备份为 *.fishros-bak),ROS 升级覆盖后再运行一次即可。 ros:humble 容器验证:FishBot 工作空间放在 /root/桌面,修复前 fishbot_msgs 失败, 修复后 9 个包全部编译通过。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_rosenv.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tools/tool_config_rosenv.py b/tools/tool_config_rosenv.py index 4e4e1ff..4de1cf7 100644 --- a/tools/tool_config_rosenv.py +++ b/tools/tool_config_rosenv.py @@ -48,5 +48,26 @@ def get_source_command(dic): FileUtils.append(shell_path,"# >>> fishros initialize >>>\n"+data+"\n# <<< fishros initialize <<<\n") # 添加 PrintUtils.print_text("") + def fix_rosidl_non_ascii_path(self): + """ + ROS2 的 rosidl_adapter 用 CMake 的 file(STRINGS) 读回接口列表时没指定 UTF-8, CMake 会把中文等非 ASCII + 字符当作分隔符丢掉: 工作空间放在 ~/桌面 这类中文路径下, colcon build 编译消息包就报 + "rosidl_generate_interfaces ... list index: 1 out of range" / "Target dependency ... .idl does not exist". + 上游 humble/jazzy/rolling 都没修, 这里给已装的 ROS2 补上 ENCODING UTF-8; ROS 升级覆盖后再运行一次本工具即可 + """ + import glob + old = 'idl_output}" idl_tuples)' + for path in glob.glob("/opt/ros/*/share/rosidl_adapter/cmake/rosidl_adapt_interfaces.cmake"): + with open(path) as f: + if old not in f.read(): + continue + CmdTask( + "sudo sed -i.fishros-bak 's#{}#idl_output}}\" idl_tuples ENCODING UTF-8)#' {}".format(old, path), 10 + ).run() + with open(path) as f: + if "idl_tuples ENCODING UTF-8)" in f.read(): + PrintUtils.print_success("已修复 {} 在中文路径下编译消息包报错的问题".format(path.split("/")[3])) + def run(self): self.config_rosenv() + self.fix_rosidl_non_ascii_path() From 8c7f4766de13e91453a26e15463142bdfc225fe1 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:13:01 -0700 Subject: [PATCH 13/45] =?UTF-8?q?feat(ros):=20=E6=96=B0=E5=A2=9E=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=2022=20=E4=B8=80=E9=94=AE=E5=AE=89=E8=A3=85=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E5=8A=9F=E8=83=BD=E5=8C=85=EF=BC=8C=E5=85=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A8=A1=E5=BC=8F=E5=8F=AF=E9=99=84=E5=B8=A6=E5=AE=89?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROS 桌面版不带导航、建图功能包,新手装完 ROS 编译 FishBot 等工作空间时 最常见的报错就是找不到 nav2_bringup 之类的包。 - 菜单 22:为已装的 ROS 一次装好导航 Nav2、建图 slam_toolbox / Cartographer、 键盘遥控、xacro / 关节调试界面、robot_localization、pyserial; 逐个检查软件源,没有的跳过(如 noetic 没有 cartographer-ros),装完逐个核对 - 全自动模式确认时新增「全自动安装 + 常用功能包」,仍然只需确认一次 - 菜单 4 的说明里注明会修复中文路径编译报错;README / CI 用例同步 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- install.py | 10 ++- tests/fish_install_test.yaml | 8 +-- tools/tool_install_ros_auto.py | 14 +++- tools/tool_install_ros_pkgs.py | 109 ++++++++++++++++++++++++++++++ tools/translation/assets/en_US.py | 3 +- 6 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 tools/tool_install_ros_pkgs.py diff --git a/README.md b/README.md index 0c18b7e..876ba6b 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ - [16] 一键安装:系统自带ROS(仅供特殊情况下使用) **ROS开发工具** -- [4] 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择) [贡献@小鱼](https://github.com/fishros) +- [4] 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错) [贡献@小鱼](https://github.com/fishros) - [3] 一键安装:rosdepc(小鱼的国内版rosdep,又快又好用) [贡献@小鱼](https://github.com/fishros) +- [22] 一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等) - [9] 一键安装:Cartographer(仅支持ROS1:Ubuntu16/18/20) 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) - [12] 一键安装:MicroROS开发环境(PlatformIO,支持FishBot) diff --git a/install.py b/install.py index 7151670..8310652 100644 --- a/install.py +++ b/install.py @@ -29,7 +29,7 @@ "tip": "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)", "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros_auto.py", - "dep": [1, 4, 5], + "dep": [1, 4, 5, 22], }, 1: { "tip": "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)", @@ -51,7 +51,7 @@ }, # ---------------- ROS开发工具 ---------------- 4: { - "tip": "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择)", + "tip": "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)", "type": CAT_ROS_TOOLS, "tool": "tools/tool_config_rosenv.py", "dep": [], @@ -62,6 +62,12 @@ "tool": "tools/tool_config_rosdep.py", "dep": [], }, + 22: { + "tip": "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等)", + "type": CAT_ROS_TOOLS, + "tool": "tools/tool_install_ros_pkgs.py", + "dep": [1], + }, 9: { "tip": "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)", "type": CAT_ROS_TOOLS, diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index 725192e..24f00f5 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -119,11 +119,11 @@ - name: "Install_ROS_focal_auto" target_os_version: "focal" chooses: - - { choose: 21, desc: "一键安装(新手推荐):全自动安装ROS" } + - { choose: 21, desc: "一键安装:ROS全自动版(新手推荐)" } - { choose: 1, desc: "开始全自动安装" } -- name: "Install_ROS_jammy_auto" +- name: "Install_ROS_jammy_auto_with_pkgs" target_os_version: "jammy" chooses: - - { choose: 21, desc: "一键安装(新手推荐):全自动安装ROS" } - - { choose: 1, desc: "开始全自动安装" } + - { choose: 21, desc: "一键安装:ROS全自动版(新手推荐)" } + - { choose: 2, desc: "全自动安装 + 常用功能包(导航/建图)" } diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index a8cc72a..dd29dfc 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -72,7 +72,8 @@ def show_plan(self, expect_version, desktop): PrintUtils.print_info(" 1. 更换系统软件源: 自动测速, 选最快的镜像(原配置会备份)") PrintUtils.print_info(" 2. 添加ROS软件源: 自动测速选镜像, 遇到坏掉的镜像自动换下一个") PrintUtils.print_info(" 3. 安装 {}".format(ros_name)) - PrintUtils.print_info(" 4. 配置环境变量, 新开终端就能直接使用ROS") + PrintUtils.print_info(" 4. 配置环境变量, 新开终端就能直接使用ROS; 工作空间放在中文路径(如 ~/桌面)下也能编译") + PrintUtils.print_info(" (选 2 会再装上导航、建图等常用功能包, 也可以以后在菜单 [22] 单独装)") if desktop: PrintUtils.print_info("预计下载 0.5~1GB, 占用磁盘 3~4GB, 用时 10~40 分钟(取决于网速)") PrintUtils.print_success("=" * 60) @@ -108,13 +109,18 @@ def run(self): expect = recommend_ros.get(codename, [None])[0] self.show_plan(expect, desktop) code, _ = ChooseTask( - {1: "开始全自动安装(推荐)", 2: "我要自己选(进入高手自定义模式)"}, + { + 1: "开始全自动安装(推荐)", + 2: "全自动安装 + 常用功能包(导航/建图等, 做FishBot等机器人项目选这个, 多下载几百MB)", + 3: "我要自己选(进入高手自定义模式)", + }, "请确认:", ).run() if code == 0: return False - if code == 2: + if code == 3: return self.ros_tool.run() + with_pkgs = code == 2 source_tool = run_tool_file("tools.tool_config_system_source", authorun=False) source_tool.change_sys_source(auto=True) @@ -146,6 +152,8 @@ def run(self): break PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) self.ros_tool.install_ros_packages(version, install_code, dic_base) + if with_pkgs and self.ros_tool.install_success(version): + run_tool_file("tools.tool_install_ros_pkgs", authorun=False).install_common(version, auto=True) self.ros_tool.config_env_and_tip(version) if self.ros_tool.install_success(version): self.print_next_steps(version, desktop) diff --git a/tools/tool_install_ros_pkgs.py b/tools/tool_install_ros_pkgs.py new file mode 100644 index 0000000..b5e182c --- /dev/null +++ b/tools/tool_install_ros_pkgs.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +import glob +import os +from .base import BaseTool +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask +from .tool_install_ros import RosVersions + +# ROS 桌面版不带导航、建图这些功能包, 新手装完 ROS 编译 FishBot 等机器人工作空间时最常缺的就是它们. +# 包名省略 ros-<版本>- 前缀; 安装前会逐个检查软件源里有没有, 没有的跳过(如 noetic 没有 cartographer-ros) +common_pkgs = { + "ROS2": [ + ("导航 Nav2", ["navigation2", "nav2-bringup"]), + ("建图 slam_toolbox", ["slam-toolbox"]), + ("建图 Cartographer", ["cartographer-ros", "cartographer-rviz"]), + ("键盘遥控", ["teleop-twist-keyboard"]), + ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), + ("多传感器融合定位", ["robot-localization"]), + ], + "ROS1": [ + ("导航 navigation", ["navigation"]), + ("建图 gmapping", ["slam-gmapping"]), + ("建图 Cartographer", ["cartographer-ros"]), + ("键盘遥控", ["teleop-twist-keyboard"]), + ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), + ("多传感器融合定位", ["robot-localization"]), + ], +} +# 与 ROS 版本无关的系统包: 串口通信, 机器人底盘驱动常用 +system_pkgs = [("串口通信 pyserial", ["python3-serial"])] + + +class Tool(BaseTool): + def __init__(self): + self.name = "一键安装ROS常用功能包(导航/建图)" + self.type = BaseTool.TYPE_INSTALL + self.author = "小鱼" + + @staticmethod + def installed_ros(): + return sorted( + os.path.basename(os.path.dirname(path)) for path in glob.glob("/opt/ros/*/setup.bash") + ) + + @staticmethod + def in_apt_index(pkg): + return CmdTask("apt-cache show {} > /dev/null 2>&1".format(pkg), 10).run()[0] == 0 + + def plan(self, version): + """返回 [(说明, [可装的完整包名])], 软件源里没有的包跳过""" + info = RosVersions.get_version(version) + ros_type = info.version if info else "ROS2" + groups = [ + (label, ["ros-{}-{}".format(version, p) for p in pkgs]) + for label, pkgs in common_pkgs.get(ros_type, common_pkgs["ROS2"]) + ] + system_pkgs + result = [] + for label, pkgs in groups: + available = [p for p in pkgs if self.in_apt_index(p)] + if available: + result.append((label, available)) + else: + PrintUtils.print_info("软件源里没有 {}, 跳过".format(label)) + return result + + def install_common(self, version, auto=False): + """ + 为指定 ROS 版本安装常用功能包. auto=True 时不再询问(全自动模式调用) + 返回是否全部安装成功 + """ + AptUtils.checkapt() + groups = self.plan(version) + if not groups: + PrintUtils.print_error("软件源里找不到 {} 的功能包, 请先用一键安装 [1] 添加 ROS 软件源".format(version)) + return False + PrintUtils.print_success("将为 {} 安装以下常用功能包:".format(RosVersions.get_version_string(version) or version)) + for label, pkgs in groups: + PrintUtils.print_info(" - {}: {}".format(label, " ".join(pkgs))) + if not auto: + code, _ = ChooseTask({1: "安装以上全部(额外下载几百MB)"}, "请确认:").run() + if code != 1: + return False + + pkgs = [p for _, group in groups for p in group] + cmd = "sudo apt install -y {}".format(" ".join(pkgs)) + result = CmdTask(cmd, 0).run() + if result[0] != 0 and FileUtils.check_result(result, ["Unable to fetch", "--fix-missing", "无法下载"]): + PrintUtils.print_warn("部分软件包下载失败, 刷新软件源后重试一次...") + CmdTask("sudo apt update", 100).run() + CmdTask("sudo apt install -y --fix-missing {}".format(" ".join(pkgs)), 0).run() + + failed = [p for p in pkgs if CmdTask("dpkg -s {} > /dev/null 2>&1".format(p), 10).run()[0] != 0] + if failed: + PrintUtils.print_error("以下功能包没有装上: {}".format(" ".join(failed))) + PrintUtils.print_info("可以稍后手动重试: sudo apt install -y {}".format(" ".join(failed))) + return False + PrintUtils.print_success("常用功能包已全部装好, 新开终端即可使用") + return True + + def run(self): + versions = self.installed_ros() + if not versions: + PrintUtils.print_error("当前系统还没有安装ROS, 请先使用一键安装 [21] 或 [1] 安装ROS") + return False + version = versions[0] + if len(versions) > 1: + code, version = ChooseTask(versions, "检测到安装了多个ROS, 请选择要给哪个版本安装功能包:", True).run() + if code == 0: + return False + return self.install_common(version) diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 4127b25..2aea45f 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -30,7 +30,8 @@ "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS - Custom (choose mirrors and version, ROS1/ROS2, Raspberry Pi/Jetson)", "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", - "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择)": "Configure ROS environment (update shell setup, auto-generate version switch)", + "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)": "Configure ROS environment (update shell setup, version switch, fix builds under non-ASCII paths)", + "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等)": "Install common ROS packages (Nav2, slam_toolbox, Cartographer, teleop, ...)", "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)": "Install rosdepc (rosdep with China mirrors)", "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)": "Install Cartographer (Google SLAM, ROS1 only: Ubuntu 16/18/20)", "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)": "Install micro-ROS dev environment (PlatformIO, supports FishBot)", From ed43ab96b846bc37b3680901e0f36d104a645f83 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:55:22 -0700 Subject: [PATCH 14/45] =?UTF-8?q?fix:=20=E7=BB=88=E7=AB=AF=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=20UTF-8=20=E6=97=B6=E4=B8=8D=E5=86=8D=E4=B8=80?= =?UTF-8?q?=E7=9B=B4=E6=8F=90=E7=A4=BA=20Finish!=20Please=20Try=20Again!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社区帖 24:中文系统但终端编码不是 UTF-8 时,一键安装装完中文语言包就提示 「Finish! Please Try Again!」退出,语言包没生效时重跑永远停在这一步。 两个原因,分别修复: 1. base.py 判断编码用的是区分大小写的 encoding.find("UTF")。Python 3.11+ 在 LANG 未设置(C/POSIX)时会自动切到 UTF-8 并返回小写 "utf-8",被误判为不支持, Ubuntu 24.04 / Debian 12 上 SSH、Docker 里运行都会中招。改为忽略大小写。 2. 真正非 UTF-8 的终端(GBK/GB18030,或 18.04 的 Python 3.6 在 C 区域设置下): install.py 启动时直接用系统自带的 C.UTF-8 重新启动自己;重启后仍不是 UTF-8 才走原来装语言包的流程。 容器验证:24.04 不设 LANG、LC_ALL=C、以及 22.04 终端设为 zh_CN.GB18030 三种情况 都能直接进入菜单(GB18030 时打印 restarting with C.UTF-8)。 Co-Authored-By: Claude Opus 5.5 --- install.py | 19 +++++++++++++++++++ tools/base.py | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index 8310652..c0bedfa 100644 --- a/install.py +++ b/install.py @@ -299,7 +299,26 @@ def main(): ) +def ensure_utf8(): + """ + 终端编码不是 UTF-8(LANG 没设置、GBK 等)时, 打印中文会出错. 原来的做法是装中文语言包后让用户重跑, + 但语言包没生效时就会一直提示 Finish! Please Try Again!(社区帖 24). + 改为直接用系统自带的 C.UTF-8 重新启动自己; 重启后仍不是 UTF-8 才走原来的语言包流程 + """ + import locale + import sys + + if "UTF" in locale.getpreferredencoding().upper() or os.environ.get("FISHROS_UTF8_RESTARTED"): + return + print("Terminal encoding is not UTF-8, restarting with C.UTF-8 ...") + sys.stdout.flush() + env = dict(os.environ, LANG="C.UTF-8", LC_ALL="C.UTF-8", PYTHONIOENCODING="utf-8") + env.update(PYTHONUTF8="1", FISHROS_UTF8_RESTARTED="1") + os.execve(sys.executable, [sys.executable] + sys.argv, env) + + if __name__ == "__main__": + ensure_utf8() run_exc = [] try: diff --git a/tools/base.py b/tools/base.py index 646a679..1377169 100644 --- a/tools/base.py +++ b/tools/base.py @@ -25,7 +25,9 @@ print("WARN:No Yaml Module!") encoding = locale.getpreferredencoding() -encoding_utf8 = encoding.find("UTF") > -1 +# Python 3.11+ 在 C/POSIX 区域设置下自动切到 UTF-8 时返回的是小写 "utf-8", 要忽略大小写判断, +# 否则会误判为不支持 UTF-8, 一直提示 Finish! Please Try Again! +encoding_utf8 = "UTF" in encoding.upper() tr = None From 1124ea01ab5e229b3f206ead3a3be3d8e4d3bbe8 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:55:22 -0700 Subject: [PATCH 15/45] =?UTF-8?q?fix(ros):=20=E5=AE=89=E8=A3=85=E5=89=8D?= =?UTF-8?q?=E7=AD=89=E5=BE=85=20apt=20=E9=94=81=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8A=E6=AC=A1=E8=A2=AB=E6=89=93=E6=96=AD=E7=9A=84=E5=AE=89?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新手最常见的两种 apt 报错,在装 ROS(菜单 1/21) 和装功能包(菜单 22) 前先处理: - 刚开机时系统自动更新占着 apt 锁:Could not get lock /var/lib/dpkg/lock-frontend → 检测到锁被占用就等待,最多 5 分钟 - 上次安装中途 Ctrl+C(比如自定义模式选到一半放弃、改用全自动重来): dpkg was interrupted, you must manually run 'dpkg --configure -a' → 自动执行 dpkg --configure -a 容器验证:人为制造「dpkg 中断 + 另一进程占锁 20 秒」,修复前 apt 报 dpkg was interrupted;修复后等待 23 秒、自动修复,随后 apt 正常安装。 Co-Authored-By: Claude Opus 5.5 --- tools/base.py | 18 ++++++++++++++++++ tools/tool_install_ros.py | 1 + tools/tool_install_ros_auto.py | 2 ++ 3 files changed, 21 insertions(+) diff --git a/tools/base.py b/tools/base.py index 1377169..22bc765 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1495,6 +1495,24 @@ def checkapt(): return False return True + @staticmethod + def wait_and_repair(): + """ + 安装前的准备, 处理两种新手最常见的 apt 报错: + 1. 刚开机时系统自动更新占着 apt 锁: Could not get lock /var/lib/dpkg/lock-frontend + 2. 上次安装中途 Ctrl+C 留下没配置完的包: dpkg was interrupted, you must manually run 'dpkg --configure -a' + """ + locks = "/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock" + for i in range(60): + if CmdTask("fuser {} > /dev/null 2>&1".format(locks), 5).run()[0] != 0: + break + if i == 0: + PrintUtils.print_warn( + tr.tr("检测到其他程序(通常是系统自动更新)正在使用软件包管理器, 等待它结束(最多5分钟)...") + ) + time.sleep(5) + CmdTask("sudo dpkg --configure -a", 0).run() + @staticmethod def getArch(): result = CmdTask("dpkg --print-architecture", 2).run() diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 83ec2b8..0b6312a 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -1074,6 +1074,7 @@ def install_ros(self): if not self.support_install(): return False + AptUtils.wait_and_repair() self.check_sys_source() self.add_key() self.add_source() diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index dd29dfc..d853c7e 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -122,6 +122,8 @@ def run(self): return self.ros_tool.run() with_pkgs = code == 2 + # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 + AptUtils.wait_and_repair() source_tool = run_tool_file("tools.tool_config_system_source", authorun=False) source_tool.change_sys_source(auto=True) From ba2efc2d1bdfb3059f68763a69bf17417fea9acd Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:55:23 -0700 Subject: [PATCH 16/45] =?UTF-8?q?feat(ros):=20=E5=B8=B8=E7=94=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=8C=85=E5=8A=A0=E5=85=A5=E6=89=8B=E6=9F=84=E9=81=A5?= =?UTF-8?q?=E6=8E=A7(joy=20/=20teleop=5Ftwist=5Fjoy=20/=20jstest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公司机器人普遍用 USB 接收器的手柄遥控。菜单 22 增加 ros-<版本>-joy、 ros-<版本>-teleop-twist-joy 和系统包 joystick(jstest 测试手柄),四个 ROS 版本 (humble/jazzy/foxy/noetic)都已核对包名存在;装完提示怎么测试和用手柄控制机器人。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_ros_pkgs.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tools/tool_install_ros_pkgs.py b/tools/tool_install_ros_pkgs.py index b5e182c..87e7ca7 100644 --- a/tools/tool_install_ros_pkgs.py +++ b/tools/tool_install_ros_pkgs.py @@ -13,6 +13,7 @@ ("建图 slam_toolbox", ["slam-toolbox"]), ("建图 Cartographer", ["cartographer-ros", "cartographer-rviz"]), ("键盘遥控", ["teleop-twist-keyboard"]), + ("手柄遥控(USB 接收器/有线手柄)", ["joy", "teleop-twist-joy"]), ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), ("多传感器融合定位", ["robot-localization"]), ], @@ -21,12 +22,16 @@ ("建图 gmapping", ["slam-gmapping"]), ("建图 Cartographer", ["cartographer-ros"]), ("键盘遥控", ["teleop-twist-keyboard"]), + ("手柄遥控(USB 接收器/有线手柄)", ["joy", "teleop-twist-joy"]), ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), ("多传感器融合定位", ["robot-localization"]), ], } # 与 ROS 版本无关的系统包: 串口通信, 机器人底盘驱动常用 -system_pkgs = [("串口通信 pyserial", ["python3-serial"])] +system_pkgs = [ + ("串口通信 pyserial", ["python3-serial"]), + ("手柄测试工具 jstest", ["joystick"]), +] class Tool(BaseTool): @@ -67,6 +72,7 @@ def install_common(self, version, auto=False): 为指定 ROS 版本安装常用功能包. auto=True 时不再询问(全自动模式调用) 返回是否全部安装成功 """ + AptUtils.wait_and_repair() AptUtils.checkapt() groups = self.plan(version) if not groups: @@ -94,8 +100,23 @@ def install_common(self, version, auto=False): PrintUtils.print_info("可以稍后手动重试: sudo apt install -y {}".format(" ".join(failed))) return False PrintUtils.print_success("常用功能包已全部装好, 新开终端即可使用") + self.print_joy_tips(version) return True + @staticmethod + def print_joy_tips(version): + info = RosVersions.get_version(version) + ros1 = info is not None and info.version == "ROS1" + PrintUtils.print_info("手柄怎么用: 插上 USB 接收器(虚拟机要在 VMware 菜单「虚拟机 > 可移动设备」里把它连到虚拟机)") + PrintUtils.print_info(" 1. 测试手柄是否被识别: jstest /dev/input/js0 (按键/摇杆时数字会变化)") + if ros1: + PrintUtils.print_info(" 2. 读取手柄数据: rosrun joy joy_node, 另开终端 rostopic echo /joy") + PrintUtils.print_info(" 3. 手柄控制机器人: roslaunch teleop_twist_joy teleop.launch joy_config:=xbox") + else: + PrintUtils.print_info(" 2. 读取手柄数据: ros2 run joy joy_node, 另开终端 ros2 topic echo /joy") + PrintUtils.print_info(" 3. 手柄控制机器人: ros2 launch teleop_twist_joy teleop-launch.py joy_config:=xbox") + PrintUtils.print_info(" (teleop_twist_joy 默认要按住使能键才会发速度, 不同手柄键位可换 joy_config:=ps3 等)") + def run(self): versions = self.installed_ros() if not versions: From 0b15c37c322013208ca6f25b813b4c0ad778ec0f Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 02:56:04 -0700 Subject: [PATCH 17/45] =?UTF-8?q?docs(menu):=20=E8=8F=9C=E5=8D=95=2022=20?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E9=87=8C=E6=B3=A8=E6=98=8E=E5=8C=85=E5=90=AB?= =?UTF-8?q?=E6=89=8B=E6=9F=84=E9=81=A5=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- install.py | 2 +- tools/translation/assets/en_US.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 876ba6b..b90f44e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ **ROS开发工具** - [4] 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错) [贡献@小鱼](https://github.com/fishros) - [3] 一键安装:rosdepc(小鱼的国内版rosdep,又快又好用) [贡献@小鱼](https://github.com/fishros) -- [22] 一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等) +- [22] 一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等) - [9] 一键安装:Cartographer(仅支持ROS1:Ubuntu16/18/20) 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) - [12] 一键安装:MicroROS开发环境(PlatformIO,支持FishBot) diff --git a/install.py b/install.py index c0bedfa..534e1b1 100644 --- a/install.py +++ b/install.py @@ -63,7 +63,7 @@ "dep": [], }, 22: { - "tip": "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等)", + "tip": "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等)", "type": CAT_ROS_TOOLS, "tool": "tools/tool_install_ros_pkgs.py", "dep": [1], diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 2aea45f..86c8df4 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -31,7 +31,7 @@ "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)": "Configure ROS environment (update shell setup, version switch, fix builds under non-ASCII paths)", - "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘遥控等)": "Install common ROS packages (Nav2, slam_toolbox, Cartographer, teleop, ...)", + "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等)": "Install common ROS packages (Nav2, slam_toolbox, Cartographer, keyboard/joystick teleop, ...)", "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)": "Install rosdepc (rosdep with China mirrors)", "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)": "Install Cartographer (Google SLAM, ROS1 only: Ubuntu 16/18/20)", "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)": "Install micro-ROS dev environment (PlatformIO, supports FishBot)", From 1bb7233947f4e2901928cfce06c7e3ba82634c2c Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 04:15:59 -0700 Subject: [PATCH 18/45] =?UTF-8?q?fix(ros):=20=E5=85=AC=E9=92=A5=E9=9A=8F?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=AE=89=E8=A3=85=E5=8F=91=E5=B8=83=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E8=AF=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=85=A8=E6=96=B0?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=B8=8A=E5=AF=86=E9=92=A5=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E5=85=9C=E5=BA=95=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在全新安装的 Ubuntu 22.04 虚拟机上实测,三条获取公钥的途径同时失败, 导致所有 ROS 源都 NO_PUBKEY: 1. gitee 下载中途 TLS 连接被重置(curl 56),而 curl --retry 不重试这类错误; 2. GitHub raw 在国内连不上; 3. 密钥服务器兜底在全新系统上必然失败:root 没用过 gpg,/root/.gnupg 不存在, gpg 起不来 dirmngr(「缺少 dirmngr」)。原版用 apt-key adv 会自建临时目录所以没这问题。 修复: - 公钥随一键安装一起发布(tools/keys/ros.asc,取自 ros/rosdistro,指纹 C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654,有效期至 2030-06-01),作为第一个下载地址: 能下载到一键安装就一定能下载到它;其后是 gitee、GitHub - 每个地址最多试 3 次;下载到的公钥校验指纹,不是 ROS 官方公钥一律不用 - 所有 gpg 操作都用临时 GNUPGHOME;密钥服务器 80 和 11371 端口都试, 失败时清理 tee 留下的空文件 Co-Authored-By: Claude Opus 5.5 --- tools/keys/ros.asc | 42 ++++++++++++++ tools/tool_install_ros.py | 112 ++++++++++++++++++++++++++------------ 2 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 tools/keys/ros.asc diff --git a/tools/keys/ros.asc b/tools/keys/ros.asc new file mode 100644 index 0000000..29454bc --- /dev/null +++ b/tools/keys/ros.asc @@ -0,0 +1,42 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 + +mQINBFzvJpYBEADY8l1YvO7iYW5gUESyzsTGnMvVUmlV3XarBaJz9bGRmgPXh7jc +VFrQhE0L/HV7LOfoLI9H2GWYyHBqN5ERBlcA8XxG3ZvX7t9nAZPQT2Xxe3GT3tro +u5oCR+SyHN9xPnUwDuqUSvJ2eqMYb9B/Hph3OmtjG30jSNq9kOF5bBTk1hOTGPH4 +K/AY0jzT6OpHfXU6ytlFsI47ZKsnTUhipGsKucQ1CXlyirndZ3V3k70YaooZ55rG +aIoAWlx2H0J7sAHmqS29N9jV9mo135d+d+TdLBXI0PXtiHzE9IPaX+ctdSUrPnp+ +TwR99lxglpIG6hLuvOMAaxiqFBB/Jf3XJ8OBakfS6nHrWH2WqQxRbiITl0irkQoz +pwNEF2Bv0+Jvs1UFEdVGz5a8xexQHst/RmKrtHLct3iOCvBNqoAQRbvWvBhPjO/p +V5cYeUljZ5wpHyFkaEViClaVWqa6PIsyLqmyjsruPCWlURLsQoQxABcL8bwxX7UT +hM6CtH6tGlYZ85RIzRifIm2oudzV5l+8oRgFr9yVcwyOFT6JCioqkwldW52P1pk/ +/SnuexC6LYqqDuHUs5NnokzzpfS6QaWfTY5P5tz4KHJfsjDIktly3mKVfY0fSPVV +okdGpcUzvz2hq1fqjxB6MlB/1vtk0bImfcsoxBmF7H+4E9ZN1sX/tSb0KQARAQAB +tCZPcGVuIFJvYm90aWNzIDxpbmZvQG9zcmZvdW5kYXRpb24ub3JnPokCVAQTAQgA +PgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgBYhBMHPbjHmut6IaLFytPQu1vur +F8ZUBQJgsdhRBQkLTMW7AAoJEPQu1vurF8ZUTMwP/3f7EkOPIFjUdRmpNJ2db4iB +RQu5b2SJRG+KIdbvQBzKUBMV6/RUhEDPjhXZI3zDevzBewvAMKkqs2Q1cWo9WV7Z +PyTkvSyey/Tjn+PozcdvzkvrEjDMftIk8E1WzLGq7vnPLZ1q/b6Vq4H373Z+EDWa +DaDwW72CbCBLWAVtqff80CwlI2x8fYHKr3VBUnwcXNHR4+nRABfAWnaU4k+oTshC +Qucsd8vitNfsSXrKuKyz91IRHRPnJjx8UvGU4tRGfrHkw1505EZvgP02vXeRyWBR +fKiL1vGy4tCSRDdZO3ms2J2m08VPv65HsHaWYMnO+rNJmMZj9d9JdL/9GRf5F6U0 +quoIFL39BhUEvBynuqlrqistnyOhw8W/IQy/ymNzBMcMz6rcMjMwhkgm/LNXoSD1 +1OrJu4ktQwRhwvGVarnB8ihwjsTxZFylaLmFSfaA+OAlOqCLS1OkIVMzjW+Ul6A6 +qjiCEUOsnlf4CGlhzNMZOx3low6ixzEqKOcfECpeIj80a2fBDmWkcAAjlHu6VBhA +TUDG9e2xKLzV2Z/DLYsb3+n9QW7KO0yZKfiuUo6AYboAioQKn5jh3iRvjGh2Ujpo +22G+oae3PcCc7G+z12j6xIY709FQuA49dA2YpzMda0/OX4LP56STEveDRrO+CnV6 +WE+F5FaIKwb72PL4rLi4iQJUBBMBCAA+AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B +AheAFiEEwc9uMea63ohosXK09C7W+6sXxlQFAmgSGgYFCRS0dnAACgkQ9C7W+6sX +xlS/UA//aAgP67DunDdak96+fLemWJkl4PHhj6637lzacJ+SlRzeUbnS/2XLhmk1 +BNYoib3IHp3GBqvLsQqkCUZWaJTvkkAvJ+1W2N7JByt7Z/tnTS7aVfDxF53nYCxY +eSH921y2AtIZCIl1N3R2ic7pyzNkVVqwKIV1EqWLMa8GQTy4V0pgwaLE6Ce9Bmtv +04upGyiPXRoPM3Rfc0mTUtPGJLf651img6TYGb1UbKs2aAitiI2ptg8EdiRYYcGo +nG8Ar3aUnYj+fpfhTyvqwx0MTtAPDiMUx2vELReYIvhwU+SRHWpp20nL0WIK2krK +qIq5SwIboBSLkQ5j7tjehKkqfxanUrlUxu/XYlEhq0Mh5oCfBrarIFBUBULUX86p +ZQUqW4+MrIxHcNcrCPGm3U/4dSZ1rTAdyeEUi7a2H96CYYofl7dq1xXGMDFh+b5/ +3Yw3t8US4VCwxmEj+C3ciARJauB1oDOilEieszPvIS3PdVpp6HCZRRHaB689AzMF +FoD40iowsNS9XmO6O8V7xzVVS0EtNhz9qUGIz8yjWeLLdpR8NqHOFOvrPP66voEV +Gc0Va/nozc05WWt42bc0hs1faRMqHRlAlJIKSUm4NSqc+YDNPYFlZSnB97tBhHC9 +CEXRgHY3Utq/I3CLJ+KcJCUCH5D16Z7aOoazG9DKbewA+da8Drw= +=9IZg +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 0b6312a..685b3b8 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os import re from .base import BaseTool from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask @@ -723,30 +724,61 @@ def add_source(self, auto=False): ) return False + @staticmethod + def key_sources(): + """ + 公钥下载地址, 按可靠程度排序. 第一个是随一键安装一起发布的公钥(tools/keys/ros.asc): + 用户既然能下载到一键安装, 就一定能从同一台服务器下载到它 + """ + prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") + return [os.path.join(prefix, "tools/keys/ros.asc")] + key_urls + + @staticmethod + def gpg_cmd(*commands): + """ + 拼出一段 shell: 依次执行若干条 gpg 命令, 都用同一个临时目录当 GNUPGHOME, 结束后删掉. + 全新系统里 root 从没用过 gpg, /root/.gnupg 不存在, gpg 连密钥服务器时起不来 dirmngr + ("缺少 dirmngr"), 导入/校验也会因为建不了锁文件失败 + """ + gpg = 'sudo env GNUPGHOME="$d" gpg --batch ' + body = " && ".join(gpg + c for c in commands) + return "sh -c 'd=$(mktemp -d) && {}; r=$?; rm -rf \"$d\"; exit $r'".format(body) + + def key_fingerprint_ok(self, key_file): + """校验公钥是不是 ROS 官方那把; 没有 gpg 时只能跳过""" + if not self.has_command("gpg"): + return True + result = CmdTask( + self.gpg_cmd("--with-colons --import-options show-only --import {}".format(key_file)), 20 + ).run() + return ros_key_fingerprint in " ".join(result[1]) + def download_ros_key(self): """ - 依次尝试各个公钥地址, 下载并校验通过后返回本地文件路径. - gitee raw 现在会 302 跳到 raw.giteeusercontent.com, curl 必须带 -L, 否则拿到的是一段 HTML + 依次尝试各个公钥地址, 每个地址最多试 3 次(网络抖动时 TLS 连接会被重置, curl 的 --retry 不重试这种错误), + 内容是 PGP 公钥且指纹正确才采用, 返回本地文件路径 """ key_file = "/tmp/fishros_ros.asc" - # wget 一定有(用户就是用它下载的一键安装), curl 不一定 + # wget 一定有(用户就是用它下载的一键安装), curl 不一定; gitee 会 302 跳转, curl 必须带 -L if self.has_command("curl"): - download = "curl -fsSL --retry 2 --max-time 30 {url} -o {out}" + download = "curl -fsSL --max-time 30 {url} -o {out}" else: - download = "wget -q --tries=2 --timeout=30 -O {out} {url}" - PrintUtils.print_success(tr.tr("正在挑选最快的密钥服务:{}").format(key_urls)) - for key_url in AptUtils.get_fast_url(key_urls): - CmdTask("rm -f {}".format(key_file), 3).run() - result = CmdTask(download.format(url=key_url, out=key_file), 40).run() - if result[0] != 0: - continue - check = CmdTask( - "grep -q 'BEGIN PGP PUBLIC KEY BLOCK' {}".format(key_file), 3 - ).run() - if check[0] == 0: - PrintUtils.print_success(tr.tr("已自动选择最快密钥服务:{}").format(key_url)) + download = "wget -q --timeout=30 -O {out} {url}" + sources = self.key_sources() + PrintUtils.print_success(tr.tr("正在获取ROS公钥:{}").format(sources)) + for key_url in sources: + for attempt in range(3): + CmdTask("rm -f {}".format(key_file), 3).run() + if CmdTask(download.format(url=key_url, out=key_file), 40).run()[0] != 0: + continue + if CmdTask("grep -q 'BEGIN PGP PUBLIC KEY BLOCK' {}".format(key_file), 3).run()[0] != 0: + PrintUtils.print_warn("{} 返回的不是公钥, 尝试下一个地址".format(key_url)) + break + if not self.key_fingerprint_ok(key_file): + PrintUtils.print_warn("{} 的公钥指纹不对, 不予采用".format(key_url)) + break + PrintUtils.print_success(tr.tr("已获取ROS公钥:{}").format(key_url)) return key_file - PrintUtils.print_warn("{} 返回的不是有效公钥, 尝试下一个地址".format(key_url)) return None def add_key(self): @@ -764,12 +796,13 @@ def add_key(self): key_file = self.download_ros_key() if key_file: # 统一导入 trusted.gpg.d, 新旧系统都认, 不依赖已弃用的 apt-key - cmd_result = CmdTask( - "sudo gpg --batch --yes --dearmor -o {0} {1} && sudo chmod 644 {0}".format( - ros_keyring, key_file - ), - 20, - ).run() + if self.has_command("gpg"): + cmd_result = CmdTask( + "{} && sudo chmod 644 {}".format( + self.gpg_cmd("--yes --dearmor -o {} {}".format(ros_keyring, key_file)), ros_keyring + ), + 20, + ).run() if cmd_result[0] != 0: # 没有 gpg(精简系统/容器, 或系统源坏了装不上 gnupg)时直接放 .asc 公钥 PrintUtils.print_info("gpg 不可用, 改为直接导入 ASCII 格式公钥") @@ -782,20 +815,27 @@ def add_key(self): if cmd_result[0] != 0 and self.has_command("apt-key"): cmd_result = CmdTask("sudo apt-key add {}".format(key_file), 20).run() - if cmd_result[0] != 0: + if cmd_result[0] != 0 and self.has_command("gpg"): PrintUtils.print_info(tr.tr("导入密钥失败,开始更换导入方式并二次尝试...")) - # 11371 端口经常不通, 走 80 端口; 用完整指纹 - tmp_keyring = "/tmp/fishros_ros_keyring.gpg" - cmd_result = CmdTask( - "sudo rm -f {1} && " - "sudo gpg --batch --no-default-keyring --keyring {1} " - "--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys {0} && " - "sudo gpg --batch --no-default-keyring --keyring {1} --export {0} " - "| sudo tee {2} > /dev/null && sudo chmod 644 {2}".format( - ros_key_fingerprint, tmp_keyring, ros_keyring - ), - 60, - ).run() + # 从密钥服务器取, 用完整指纹; 有的网络 80 端口不通、有的 11371 端口不通, 两个都试 + for keyserver in ("hkp://keyserver.ubuntu.com:80", "hkp://keyserver.ubuntu.com"): + cmd_result = CmdTask( + "{} | sudo tee {} > /dev/null && sudo chmod 644 {}".format( + self.gpg_cmd( + "--keyserver {} --recv-keys {} >&2".format(keyserver, ros_key_fingerprint), + "--export {}".format(ros_key_fingerprint), + ), + ros_keyring, + ros_keyring, + ), + 60, + ).run() + if cmd_result[0] == 0 and CmdTask("test -s {}".format(ros_keyring), 3).run()[0] == 0: + break + cmd_result = (1, [], []) + if cmd_result[0] != 0: + # 取不到时 tee 会留下空文件, apt 会对它报警告, 删掉 + CmdTask("[ -s {0} ] || sudo rm -f {0}".format(ros_keyring), 3).run() if cmd_result[0] != 0: PrintUtils.print_error( From 99ef5dbe5ee096ed1de6941b5b4ba2cdbd0253c9 Mon Sep 17 00:00:00 2001 From: sixiaopangai <2904153061@qq.com> Date: Mon, 12 Oct 2026 04:27:29 -0700 Subject: [PATCH 19/45] =?UTF-8?q?fix(translator):=20=E6=9F=A5=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=20IP=20=E6=89=80=E5=9C=A8=E5=9B=BD=E5=AE=B6=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E7=B3=BB=E7=BB=9F=E8=AF=AD=E8=A8=80=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B8=80=E5=BE=8B=E8=8B=B1=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新装的 Ubuntu 22.04 虚拟机恢复快照后 DNS 失效,按 IP 查国家的请求失败, 一键安装整个界面变成了英文,而系统语言是 zh_CN.UTF-8。 查询失败(断网、DNS 故障、接口不可用)时原来固定返回 en_US。以前看不出来,是因为 sudo 清掉了 FISHROS_URL,语言包根本没下载,界面始终是中文;FISHROS_URL 透传后 语言包能下载了,这个问题才暴露出来。 改为查询失败时按系统语言:英文系统用英文,其它(中文、C/POSIX 等)用中文。 直接读 LC_ALL/LC_MESSAGES/LANG,因为 locale.getdefaultlocale() 会把 C.UTF-8 规范化成 en_US。查询成功时逻辑不变。 Co-Authored-By: Claude Opus 5.5 --- tools/translation/translator.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tools/translation/translator.py b/tools/translation/translator.py index dd89133..0bee0a8 100644 --- a/tools/translation/translator.py +++ b/tools/translation/translator.py @@ -29,7 +29,8 @@ def __init__(self): self.country = 'CN' self._currentLocale = self.getLocalFromIP() if self._currentLocale is None: - self._currentLocale = locale.getdefaultlocale()[0] + # 查不到 IP 所在国家(断网、DNS 故障、接口不可用)时按系统语言来, 而不是一律英文 + self._currentLocale = self.getLocalFromSystem() # Load the translation file. self.lang = self._currentLocale # Create directory for downloads @@ -77,6 +78,21 @@ def tr(self, string) -> str: def isCN(self) -> bool: return self.country == 'CN' + @staticmethod + def getLocalFromSystem() -> str: + """ + 按系统语言选: 英文系统用英文, 其它情况(中文、C/POSIX 等没有语言偏好)用中文(工具本身的语言). + 直接读环境变量: locale.getdefaultlocale() 会把 C.UTF-8 规范化成 en_US, 不能用 + """ + system_locale = "" + for name in ("LC_ALL", "LC_MESSAGES", "LANG"): + if os.environ.get(name): + system_locale = os.environ[name].lower() + break + if system_locale.startswith("en"): + return "en_US" + return "zh_CN" + def getLocalFromIP(self) -> str: local_str = "" temp_file = "/tmp/fishros_check_country.xml" @@ -95,9 +111,9 @@ def getLocalFromIP(self) -> str: else: local_str = "en_US" else: - local_str = "en_US" + local_str = None except Exception: - local_str = "en_US" + local_str = None finally: try: os.remove(temp_file) From 41cdc18362cbe98dbd574e47f8ea7e9faecd60c3 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:44:34 -0700 Subject: [PATCH 20/45] =?UTF-8?q?fix(ros):=20=E6=8E=A2=E6=B5=8B=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=8C=85=E6=97=B6=E5=B8=A6=20apt=20=E7=9A=84=20User-A?= =?UTF-8?q?gent=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=AD=E7=A7=91=E5=A4=A7?= =?UTF-8?q?=E8=A2=AB=E8=AF=AF=E5=88=A4=E4=B8=BA=E5=8C=85=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中科大镜像对 wget 默认 UA 一律返回 403。全新的 22.04 桌面版没有 curl, 若 curl 又没装上,http_status 走 wget 兜底,会把正常的中科大源当成过期源排除。 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_ros.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 685b3b8..07f1531 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -593,15 +593,17 @@ def has_command(name): def http_status(self, url): """ 返回 url 跟随跳转后的 HTTP 状态码, 取不到返回空串. - Ubuntu 22.04/24.04 桌面版默认没装 curl, 系统源坏了又装不上时用 wget 兜底 + Ubuntu 22.04/24.04 桌面版默认没装 curl, 系统源坏了又装不上时用 wget 兜底. + 带上 apt 的 User-Agent: 中科大镜像对 wget 默认 UA 一律返回 403, 会被误判成包已删除 """ + user_agent = "Debian APT-HTTP/1.3" if self.has_command("curl"): check = CmdTask( - "curl -sIL -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(url), 20 + "curl -sIL -A '{}' -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(user_agent, url), 20 ).run() return "".join(check[1]).strip() check = CmdTask( - "wget --spider -S --timeout=15 --tries=1 '{}' 2>&1 | grep 'HTTP/'".format(url), 20 + "wget --spider -S -U '{}' --timeout=15 --tries=1 '{}' 2>&1 | grep 'HTTP/'".format(user_agent, url), 20 ).run() codes = re.findall(r"HTTP/[\d.]+ (\d+)", " ".join(check[1])) return codes[-1] if codes else "" From c72e50d45c0cb44cca61ba0321501151c250414e Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:50:46 -0700 Subject: [PATCH 21/45] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E6=BA=90?= =?UTF-8?q?=E5=8F=AA=E7=94=A8=20http=20=E5=B9=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=B8=8B=E8=BD=BD=E9=80=9F=E5=BA=A6;=20?= =?UTF-8?q?=E5=B0=8F=E7=99=BD/=E9=AB=98=E6=89=8B=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E8=8F=9C=E5=8D=95=201;=20=E6=8E=A8=E5=B9=BF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=8C=89=E5=9C=BA=E6=99=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统源(菜单 5): - 只用 http 镜像(老系统证书、设备时钟不准时 https 会失败); 去掉国内不可用的 kernel.org, 加入北外、南大、华为云, 列表带镜像名 - 测速改为下载 dists/<代号>/Release: 测真实速度并确认镜像有该系统版本, 只测一次, 失败换下一个不再重测; 已停止支持的 Ubuntu 版本自动改用旧版本归档源(old-releases) - 修复手动选源时选 0 退出不了: 原来会把 "quit" 写进 sources.list 后反复要求重选; 现在退出会恢复换源前的源配置, 前两个问题选 0 也不会再继续换源 - get_fast_url 按地址协议连接(原来一律 https) 菜单 1: 进入后先选 小白模式(推荐)/高手模式, 网上教程里的"输入 1"继续有效; 菜单 21 不再单独展示. 小白模式装完列出帮用户选了什么(系统源、ROS 源、公钥、版本及原因、功能包、环境变量、中文路径修复). 推广: 内容从服务器 ads.json 读取(读不到用内置内容), 按场景展示(开始安装/装好 ROS/装好功能包/结束), 支持终端二维码; 安装失败后不展示商品推广; 淘宝链接更新. Co-Authored-By: Claude Opus 5.5 --- install.py | 30 ++-- tests/fish_install_test.yaml | 20 ++- tools/base.py | 113 ++++++++++++- tools/tool_config_system_source.py | 252 +++++++++++++++++++---------- tools/tool_install_ros.py | 19 ++- tools/tool_install_ros_auto.py | 59 ++++++- tools/tool_install_ros_pkgs.py | 9 +- tools/translation/assets/en_US.py | 6 +- 8 files changed, 387 insertions(+), 121 deletions(-) diff --git a/install.py b/install.py index 534e1b1..6d8b189 100644 --- a/install.py +++ b/install.py @@ -25,17 +25,19 @@ # 菜单里按本字典的书写顺序展示, 所以调整顺序只需要挪位置, 不要改编号 tools = { # ---------------- ROS安装 ---------------- + # 小白模式已并入菜单 1, 这里只保留条目供菜单 1 下载依赖, 不在菜单里展示 21: { "tip": "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)", "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros_auto.py", "dep": [1, 4, 5, 22], + "hidden": True, }, 1: { - "tip": "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)", + "tip": "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)", "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros.py", - "dep": [4, 5], + "dep": [4, 5, 21, 22], }, 11: { "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)", @@ -169,6 +171,8 @@ # 遍历tools字典,根据type值进行分类 for tool_id, tool_info in tools.items(): + if tool_info.get("hidden"): + continue tool_type = tool_info["type"] tool_categories[tool_type][tool_id] = tool_info @@ -200,6 +204,7 @@ def main(): ChooseTask, ChooseWithCategoriesTask, Tracking, + AdUtils, ) from tools.base import encoding_utf8, osversion, osarch from tools.base import run_tool_file, download_tools @@ -273,7 +278,8 @@ def main(): else: if url_prefix: download_tools(code, tools, url_prefix) - run_tool_file(tools[code]["tool"].replace("/", ".")) + if run_tool_file(tools[code]["tool"].replace("/", ".")) == False: + AdUtils.mark_failed() # 检查是否在 GitHub Actions 环境中运行或使用了测试配置文件 # 如果是,则跳过生成配置文件和后续的打印操作,因为这些操作需要用户输入 @@ -282,21 +288,9 @@ def main(): and os.environ.get("FISH_INSTALL_CONFIG") is None ): config_helper.gen_config_file() - PrintUtils.print_delay( - tr.tr("欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)"), 0.05 - ) - PrintUtils.print_delay( - tr.tr( - "鱼香小铺正式开业,最低599可入手一台能建图会导航的移动机器人,淘宝搜店:鱼香ROS 或打开链接查看:https://item.taobao.com/item.htm?id=696573635888" - ), - 0.001, - ) - PrintUtils.print_delay( - tr.tr( - "如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈" - ), - 0.001, - ) + # 推广内容从服务器的 ads.json 读取, 见 tools/base.py 的 AdUtils + AdUtils.show("finish_shop") + AdUtils.show("finish") def ensure_utf8(): diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index 24f00f5..dde3252 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -12,6 +12,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 1, desc: "中科大源" } - { choose: 5, desc: "支持的第5个ros版本" } # 中科大,清华,华为,中山大学源不支持Ubuntu18.04安装ros2-bouncy 其余ros2没测试 @@ -21,6 +22,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 2, desc: "清华源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -30,6 +32,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 3, desc: "华为源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -39,6 +42,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 4, desc: "中山大学源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -48,6 +52,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 6, desc: "ROS官方源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -59,6 +64,7 @@ target_os_version: "focal" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "foxy(ROS2)" } @@ -68,6 +74,7 @@ target_os_version: "focal" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 6, desc: "ROS官方源" } - { choose: 3, desc: "noetic(ROS1)" } @@ -76,6 +83,8 @@ # target_os_version: "focal" # chooses: # - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } +# - { choose: 2, desc: "高手模式" } + - { choose: 2, desc: "高手模式" } # - { choose: 2, desc: "不更换系统源再继续安装" } # - { choose: 6, desc: "ROS官方源" } # - { choose: 1, desc: "支持的第一个ros版本" } @@ -87,6 +96,7 @@ target_os_version: "jammy" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } @@ -98,6 +108,7 @@ target_os_version: "noble" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } @@ -109,21 +120,24 @@ target_os_version: "resolute" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 1, desc: "ROS国内镜像源(优先第一个)" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } ################################################################################################################################################################## -# 全自动模式(新手推荐, 菜单 21): 只需确认一次, 系统源/ROS源/版本全部自动 +# 小白模式(菜单 1 里选 1): 只需确认一次, 系统源/ROS源/版本全部自动 - name: "Install_ROS_focal_auto" target_os_version: "focal" chooses: - - { choose: 21, desc: "一键安装:ROS全自动版(新手推荐)" } + - { choose: 1, desc: "一键安装(推荐):ROS" } + - { choose: 1, desc: "小白模式" } - { choose: 1, desc: "开始全自动安装" } - name: "Install_ROS_jammy_auto_with_pkgs" target_os_version: "jammy" chooses: - - { choose: 21, desc: "一键安装:ROS全自动版(新手推荐)" } + - { choose: 1, desc: "一键安装(推荐):ROS" } + - { choose: 1, desc: "小白模式" } - { choose: 2, desc: "全自动安装 + 常用功能包(导航/建图)" } diff --git a/tools/base.py b/tools/base.py index 22bc765..c5dc4a0 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1623,10 +1623,12 @@ def get_fast_url(urls, timeout=1.5): try: # 记录开始时间 start_time = time.time() - # 创建连接并发送请求 - conn = http.client.HTTPSConnection( - host, timeout=timeout - ) # 使用 HTTPS 连接 + # 按地址本身的协议连接: 原来一律走 https, http 地址测的其实是 https 的延迟, + # 设备时钟不准时 https 证书校验失败, 所有地址都会被判成超时 + if parsed_url.scheme == "http": + conn = http.client.HTTPConnection(host, timeout=timeout) + else: + conn = http.client.HTTPSConnection(host, timeout=timeout) conn.request("GET", path) response = conn.getresponse() # 记录结束时间 @@ -1650,6 +1652,109 @@ def get_fast_url(urls, timeout=1.5): return sorted_urls +class AdUtils: + """ + 一键安装里的推广信息. 内容从服务器上的 ads.json(和 install.py 同目录)读取, 改文案、换商品不用改代码, + 读不到时用下面的内置内容. 按场景展示, 每个场景一次运行只展示一次; 安装失败后不再展示商品推广. + ads.json 格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "qr": [...], "shop": true}}} + """ + + shop_url = "https://item.taobao.com/item.htm?abbucket=0&id=1083795293714" + default_scenes = { + # 开始下载安装 ROS, 要等好几分钟 + "installing": { + "shop": True, + "lines": ["安装需要几分钟, 等待的时候可以逛逛鱼香ROS小铺: 淘宝搜店「鱼香ROS」"], + }, + # ROS 装好了 + "ros_installed": { + "shop": True, + "title": "鱼香ROS小铺", + "lines": [ + "ROS 装好了, 想让它跑在真机器人上? 小铺有能建图、会导航的 ROS 机器人", + "淘宝搜店「鱼香ROS」或打开: " + shop_url, + ], + }, + # 导航/建图等常用功能包装好了 + "pkgs_installed": { + "shop": True, + "title": "鱼香ROS小铺", + "lines": [ + "导航、建图功能包已就绪, 配一台能建图会导航的机器人就能上手实战", + "淘宝搜店「鱼香ROS」或打开: " + shop_url, + ], + }, + # 程序结束, 本次没展示过商品推广时补一条 + "finish_shop": { + "shop": True, + "lines": ["鱼香ROS小铺: ROS 学习用的移动机器人, 淘宝搜店「鱼香ROS」或打开: " + shop_url], + }, + # 程序结束, 不论成败都展示 + "finish": { + "lines": [ + "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)", + "如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈", + ], + "lines_en": [ + "Welcome to join the robot learning exchange QQ group (In Chinese): 438144612", + "If you encounter any problems, please visit https://fishros.org.cn/forum for feedback", + ], + }, + } + scenes = None + shown_scenes = set() + failed = False + + @classmethod + def load(cls): + if cls.scenes is not None: + return cls.scenes + cls.scenes = dict(cls.default_scenes) + url = os.path.join(os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/"), "ads.json") + try: + import json + import urllib.request + + with urllib.request.urlopen(url, timeout=3) as resp: + cls.scenes.update(json.loads(resp.read().decode("utf-8")).get("scenes", {})) + except Exception: + pass + return cls.scenes + + @classmethod + def mark_failed(cls): + cls.failed = True + + @classmethod + def show(cls, scene): + if scene in cls.shown_scenes: + return + ad = cls.load().get(scene) + if not ad: + return + if ad.get("shop"): + if cls.failed: + return + if scene == "finish_shop" and any(cls.load().get(s, {}).get("shop") for s in cls.shown_scenes): + return + # 推广内容是中文的, 英文界面只展示提供了英文的场景 + chinese = tr is None or getattr(tr, "lang", "zh_CN") == "zh_CN" + lines = ad.get("lines", []) if chinese else ad.get("lines_en", []) + if not lines: + return + cls.shown_scenes.add(scene) + if ad.get("title"): + PrintUtils.print_success("-" * 20 + " {} ".format(ad["title"]) + "-" * 20) + for line in lines: + PrintUtils.print_success(line) + if chinese and ad.get("qr"): + # 二维码自带黑白配色, 深色/浅色终端都能扫 + for row in ad["qr"]: + PrintUtils.print_text(row) + if ad.get("qr_tip"): + PrintUtils.print_info(ad["qr_tip"]) + + """ 定义基础任务 """ diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index d6df493..3c4cb6b 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- import os import time +import urllib.error +import urllib.request from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask from .base import osversion @@ -72,6 +74,43 @@ # Ubuntu 24.04 起默认使用 deb822 格式的 ubuntu.sources, 不再用 sources.list ubuntu_deb822_file = '/etc/apt/sources.list.d/ubuntu.sources' +# 系统镜像只用 http: 老系统证书过期、设备时钟不准时 https 会失败(与一键安装其它下载一致). +# 2026-10 实测: kernel.org 在国内只有一百多 KB/s, 网易 163 连不上, 阿里云 Ubuntu 带宽很低, 都不放进来 +ubuntu_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/ubuntu"), + ("中山大学", "http://mirror.sysu.edu.cn/ubuntu"), + ("北京外国语大学", "http://mirrors.bfsu.edu.cn/ubuntu"), + ("南京大学", "http://mirror.nju.edu.cn/ubuntu"), + ("华为云", "http://repo.huaweicloud.com/ubuntu"), + ("Ubuntu官方", "http://archive.ubuntu.com/ubuntu"), +] +# arm64/armhf(树莓派、Jetson 等)用 ubuntu-ports +ubuntu_ports_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/ubuntu-ports"), + ("中山大学", "http://mirror.sysu.edu.cn/ubuntu-ports"), + ("南京大学", "http://mirror.nju.edu.cn/ubuntu-ports"), + ("华为云", "http://repo.huaweicloud.com/ubuntu-ports"), + ("Ubuntu官方", "http://ports.ubuntu.com/ubuntu-ports"), +] +# 已停止支持的 Ubuntu 版本(如 24.10)会从普通镜像删除, 只能用旧版本归档源, 所有架构都在里面 +ubuntu_old_mirrors = [ + ("中国科学技术大学(旧版本归档)", "http://mirrors.ustc.edu.cn/ubuntu-old-releases/ubuntu"), + ("南京大学(旧版本归档)", "http://mirror.nju.edu.cn/ubuntu-old-releases/ubuntu"), + ("Ubuntu官方(旧版本归档)", "http://old-releases.ubuntu.com/ubuntu"), +] +# Debian 的镜像不区分架构, arm64/armhf 和 amd64 用同一套地址 +debian_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/debian"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/debian"), + ("中山大学", "http://mirror.sysu.edu.cn/debian"), + ("阿里云", "http://mirrors.aliyun.com/debian"), + ("南京大学", "http://mirror.nju.edu.cn/debian"), + ("华为云", "http://repo.huaweicloud.com/debian"), + ("Debian官方", "http://deb.debian.org/debian"), +] + class Tool(BaseTool): def __init__(self): @@ -79,13 +118,17 @@ def __init__(self): self.name = "一键更换系统源" self.author = '小鱼' self.use_deb822 = False + self.cancelled = False + self.backups = [] + self.ranking = {} + self.source_names = {} def add_ros_source(self): """快速添加ROS源""" dic = {1:"添加ROS/ROS2源",2:"不添加ROS/ROS2源"} code,result = ChooseTask(dic, "请问是否添加ROS和ROS2源?").run() - if code==2: return + if code!=1: return tool = run_tool_file('tools.tool_install_ros',authorun=False) if not tool.support_install(): return False tool.add_key() @@ -97,6 +140,7 @@ def backup_path(self, path): return None backup = '{}.fishros-bak-{}'.format(path, time.strftime('%Y%m%d%H%M%S')) CmdTask('sudo mv {} {}'.format(path, backup)).run() + self.backups.append((backup, path)) return backup def backup_third_party_sources(self): @@ -114,20 +158,49 @@ def backup_third_party_sources(self): CmdTask('sudo mkdir -p {}'.format(backup)).run() for f in files: CmdTask('sudo mv {}/{} {}/'.format(src_dir, f, backup)).run() + self.backups.append((backup, src_dir)) return backup + def restore_sources(self): + """用户中途选了退出: 把这次挪走、改掉的源文件恢复回去, 系统源保持换源前的样子""" + for backup, original in reversed(self.backups): + if original == '/etc/apt/sources.list.d': + CmdTask('sudo sh -c "mv {0}/* {1}/ && rmdir {0}"'.format(backup, original)).run() + else: + CmdTask('sudo mv {} {}'.format(backup, original)).run() + self.backups = [] + if getattr(self, 'original_deb822', None) is not None: + FileUtils.new('/etc/apt/sources.list.d/', 'ubuntu.sources', self.original_deb822) + PrintUtils.print_warn('已取消换源, 系统源已恢复成原来的配置') + def clean_old_source(self, auto=False): + """备份旧的源配置. 用户在提问里选了退出时返回 False, 这时还没有动任何文件""" if auto: # 全自动模式: 只换系统源, 第三方源先保留, apt update 失败时再挪走(见 change_sys_source) code = 1 + self.source_method_code = 1 else: dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() + if code == 0: + return False + PrintUtils.print_info("源选择方式说明:") + PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") + PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") + dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} + self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() + if self.source_method_code == 0: + return False + # 清理前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起挪走, 就判断不出来了 self.use_deb822 = os.path.exists(ubuntu_deb822_file) - backup = '/etc/apt/ubuntu.sources.fishros.bak' - if self.use_deb822 and not os.path.exists(backup): - CmdTask('sudo cp {} {}'.format(ubuntu_deb822_file, backup)).run() + self.original_deb822 = None + if self.use_deb822: + with open(ubuntu_deb822_file, encoding='utf-8') as f: + self.original_deb822 = f.read() + backup = '/etc/apt/ubuntu.sources.fishros.bak' + if not os.path.exists(backup): + CmdTask('sudo cp {} {}'.format(ubuntu_deb822_file, backup)).run() self.backup_path('/etc/apt/sources.list') if code==2: backup_dir = self.backup_third_party_sources() @@ -135,18 +208,7 @@ def clean_old_source(self, auto=False): PrintUtils.print_info('第三方源已备份到 {}, 如需恢复可把里面的文件挪回 /etc/apt/sources.list.d/'.format(backup_dir)) # fix add source failed before config system source CmdTask('sudo mkdir -p /etc/apt/sources.list.d').run() - - if auto: - self.source_method_code = 1 - return - - # 添加选择源的方式 - PrintUtils.print_info("源选择方式说明:") - PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") - PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") - - dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} - self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() + return True def get_debian_template(self, codename): if codename in ('wheezy', 'jessie', 'stretch', 'buster'): @@ -155,75 +217,77 @@ def get_debian_template(self, codename): return debian_amd64_sources_template return debian_firmware_sources_template + @staticmethod + def measure_source(url, codename, timeout=4): + """ + 下载镜像里的 dists/<代号>/Release 测速: 测的是真实下载速度(原来只测首包延迟, 带宽很低的镜像也会排第一), + 同时确认镜像里确实有这个系统版本. 返回 (KB/s, 状态), 状态: ok / missing(镜像里没有) / fail(连不上) + """ + # 带上 apt 的 User-Agent: 镜像对 apt 和其它下载工具的策略可能不同(中科大会拒绝 wget) + request = urllib.request.Request( + '{}/dists/{}/Release'.format(url, codename), headers={'User-Agent': 'Debian APT-HTTP/1.3'}) + start = time.time() + size = 0 + try: + with urllib.request.urlopen(request, timeout=timeout) as resp: + while time.time() - start < timeout: + chunk = resp.read(16384) + if not chunk: + break + size += len(chunk) + except urllib.error.HTTPError as e: + return 0, 'missing' if e.code == 404 else 'fail' + except Exception: + return 0, 'fail' + if size == 0: + return 0, 'fail' + return size / 1024.0 / max(time.time() - start, 0.001), 'ok' + + def rank_mirrors(self, mirrors, codename): + """逐个测速, 返回按速度从快到慢排好的地址列表, 以及是否每个连得上的镜像都没有这个系统版本""" + speeds = {} + missing = 0 + for name, url in mirrors: + self.source_names[url] = name + speed, status = self.measure_source(url, codename) + if status == 'ok': + speeds[url] = speed + PrintUtils.print_success('- {}\t{}\t速度: {:.0f}KB/s'.format(name, url, speed)) + elif status == 'missing': + missing += 1 + PrintUtils.print_info('- {}\t{}\t没有 {} 版本'.format(name, url, codename)) + else: + PrintUtils.print_info('- {}\t{}\t连接失败'.format(name, url)) + ranked = sorted(speeds, key=speeds.get, reverse=True) + return ranked, missing > 0 and not ranked + def get_source_by_system(self,system,codename,arch,failed_sources=[], return_all=False): - # 实际测试发现,阿里云虽然延时很低,但是带宽也低的离谱,一点都不用心,删掉了 - ubuntu_amd64_sources = [ - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "https://mirror.sysu.edu.cn/ubuntu", - # "https://mirrors.aliyun.com/ubuntu", - # "https://mirrors.163.com/ubuntu", - "https://mirrors.ustc.edu.cn/ubuntu", - "https://archive.ubuntu.com/ubuntu", - "https://mirrors.kernel.org/ubuntu", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "http://mirror.sysu.edu.cn/ubuntu", - # "http://mirrors.aliyun.com/ubuntu", - # "http://mirrors.163.com/ubuntu", - "http://mirrors.ustc.edu.cn/ubuntu", - "http://archive.ubuntu.com/ubuntu", - "http://mirrors.kernel.org/ubuntu", - ] - ubuntu_ports_sources = [ - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "https://mirror.sysu.edu.cn/ubuntu-ports", - "https://mirrors.ustc.edu.cn/ubuntu-ports", - "https://ports.ubuntu.com/ubuntu-ports", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "http://mirror.sysu.edu.cn/ubuntu-ports", - "http://mirrors.ustc.edu.cn/ubuntu-ports", - "http://ports.ubuntu.com/ubuntu-ports", - ] - # Debian 的镜像不区分架构, arm64/armhf 和 amd64 用同一套地址 - debian_sources = [ - "https://mirrors.tuna.tsinghua.edu.cn/debian", - "https://mirror.sysu.edu.cn/debian", - "https://mirrors.aliyun.com/debian", - # "https://mirrors.163.com/debian", - "https://mirrors.ustc.edu.cn/debian", - "https://deb.debian.org/debian", - "http://mirrors.tuna.tsinghua.edu.cn/debian", - "http://mirror.sysu.edu.cn/debian", - "http://mirrors.aliyun.com/debian", - # "http://mirrors.163.com/debian", - "http://mirrors.ustc.edu.cn/debian", - "http://deb.debian.org/debian", - ] - sources = [] - template = ubuntu_amd64_sources_template - if system=='ubuntu': - if arch=='amd64': - sources = ubuntu_amd64_sources + if system == 'ubuntu': + mirrors = ubuntu_mirrors if arch == 'amd64' else ubuntu_ports_mirrors + if arch == 'amd64': template = ubuntu_amd64_deb822_template if self.use_deb822 else ubuntu_amd64_sources_template else: - sources = ubuntu_ports_sources template = ubuntu_ports_deb822_template if self.use_deb822 else ubuntu_ports_sources_template - elif system=='debian': + else: + mirrors = debian_mirrors template = self.get_debian_template(codename) - sources = debian_sources - sources = [source for source in sources if source not in failed_sources] - if len(sources) == 0: - return None, None - PrintUtils.print_delay('搜索到可用源:{}'.format(sources),0.002) - PrintUtils.print_delay('接下来将进行自动测速以为您选择最快的源:') - fast_source = AptUtils.get_fast_url(sources) - if len(fast_source) == 0: - return None, None + # 只测一次速, 换下一个源时直接用排好的顺序, 不再重新测 + key = (system, codename, arch) + if key not in self.ranking: + PrintUtils.print_delay('正在测试各个镜像源的下载速度, 为您挑选最快的:', 0.002) + ranked, eol = self.rank_mirrors(mirrors, codename) + if eol and system == 'ubuntu': + PrintUtils.print_warn('{} 已停止官方支持, 普通镜像里已经没有它了, 改用旧版本归档源'.format(codename)) + ranked, _ = self.rank_mirrors(ubuntu_old_mirrors, codename) + self.ranking[key] = ranked - # 手动选择时返回按测速排序后的全部源 + sources = [source for source in self.ranking[key] if source not in failed_sources] + if not sources: + return None, None if return_all: - return fast_source, template - return fast_source[0], template + return sources, template + return sources[0], template def replace_source(self,failed_sources=[]): arch = AptUtils.getArch() @@ -250,17 +314,19 @@ def replace_source(self,failed_sources=[]): return None source_dict = {} for i, src in enumerate(sorted_sources, 1): - source_dict[i] = src - PrintUtils.print_info("请从测速结果中选择您想使用的镜像源:") - code, source = ChooseTask(source_dict, "请选择一个镜像源").run() - if not source: + source_dict[i] = '{} {}'.format(self.source_names.get(src, ''), src) + PrintUtils.print_info("请从测速结果中选择您想使用的镜像源(已按速度从快到慢排列):") + code, _ = ChooseTask(source_dict, "请选择一个镜像源").run() + if code == 0: + self.cancelled = True return None - PrintUtils.print_success('您选择的镜像源:{}'.format(source)) + source = sorted_sources[code - 1] + PrintUtils.print_success('您选择的镜像源:{}'.format(source_dict[code])) else: # 自动测速选择源 source, template = self.get_source_by_system(system, codename, arch, failed_sources) if not source: return None - PrintUtils.print_success('为您选择最快镜像源:{}'.format(source)) + PrintUtils.print_success('为您选择最快镜像源:{} {}'.format(self.source_names.get(source, ''), source)) data = template.replace("",codename).replace('',source.rstrip('/')) if self.use_deb822: @@ -271,7 +337,13 @@ def replace_source(self,failed_sources=[]): def change_sys_source(self, auto=False): - self.clean_old_source(auto=auto) + self.cancelled = False + self.current_source = None + self.failed_sources = [] + if not self.clean_old_source(auto=auto): + self.cancelled = True + PrintUtils.print_warn('已取消换源, 系统源没有改动') + return failed_sources = [] result = (-1, [], []) @@ -293,11 +365,15 @@ def change_sys_source(self, auto=False): PrintUtils.print_warn("更新失败,尝试更换其他源") source = self.replace_source(failed_sources) if not source: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") - return + break result = CmdTask('sudo apt update',100).run() - else: + + if self.cancelled: + self.restore_sources() + return + if not source: PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") + return self.current_source = source if result[0] == 0 else None self.failed_sources = failed_sources @@ -331,5 +407,7 @@ def exclude_server(self, source): def run(self): # 正式的运行 self.change_sys_source() + if self.cancelled: + return # 添加 ROS Source self.add_ros_source() diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 07f1531..5e9b852 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -2,7 +2,7 @@ import os import re from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils from .base import osversion from .base import run_tool_file from .base import tr @@ -585,6 +585,7 @@ def write_ros_source(self, arch, codename, mirrors): source_data += "deb [arch={}] {} {} main\n".format(arch, mirror, codename) FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + self.ros_mirrors = mirrors @staticmethod def has_command(name): @@ -780,6 +781,7 @@ def download_ros_key(self): PrintUtils.print_warn("{} 的公钥指纹不对, 不予采用".format(key_url)) break PrintUtils.print_success(tr.tr("已获取ROS公钥:{}").format(key_url)) + self.key_source = key_url return key_file return None @@ -949,6 +951,7 @@ def install_ros_packages(self, install_version, code, dic_base): if install_tool == "aptitude": AptUtils.install_pkg("aptitude") + AdUtils.show("installing") # ======================================================基础版本============================================================== if code == 2: @@ -1107,6 +1110,7 @@ def config_env_and_tip(self, version_name): "恭喜你,安装成功了,再附赠你机器人学习宝藏网站:鱼香社区:https://fishros.org.cn/forum" ) else: + AdUtils.mark_failed() PrintUtils.print_error( "安装失败了,请打开鱼香社区:https://fishros.org.cn/forum 在一键安装专区反馈问题..." ) @@ -1127,6 +1131,19 @@ def install_ros(self): self.config_env_and_tip(ros_version) if self.install_success(ros_version): RosVersions.tip_test_command(ros_version) + AdUtils.show("ros_installed") def run(self): + # 网上的教程都写"输入 1", 所以小白模式也放在菜单 1 里, 进来先选模式 + code, _ = ChooseTask( + { + 1: "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么", + 2: "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容", + }, + "请选择安装模式(不知道怎么选就选1):", + ).run() + if code == 0: + return False + if code == 1: + return run_tool_file("tools.tool_install_ros_auto", authorun=False).run() self.install_ros() diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index d853c7e..bdad200 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- +import os import shutil from urllib.parse import urlparse from .base import BaseTool -from .base import PrintUtils, AptUtils, ChooseTask +from .base import PrintUtils, AptUtils, ChooseTask, AdUtils from .base import osversion from .base import run_tool_file from .tool_install_ros import RosVersions @@ -119,7 +120,7 @@ def run(self): if code == 0: return False if code == 3: - return self.ros_tool.run() + return self.ros_tool.install_ros() with_pkgs = code == 2 # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 @@ -154,9 +155,61 @@ def run(self): break PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) self.ros_tool.install_ros_packages(version, install_code, dic_base) + pkgs_ok = None if with_pkgs and self.ros_tool.install_success(version): - run_tool_file("tools.tool_install_ros_pkgs", authorun=False).install_common(version, auto=True) + pkgs_ok = run_tool_file("tools.tool_install_ros_pkgs", authorun=False).install_common(version, auto=True) self.ros_tool.config_env_and_tip(version) if self.ros_tool.install_success(version): self.print_next_steps(version, desktop) + self.print_summary(source_tool, expect, version, desktop, pkgs_ok) + if self.ros_tool.install_success(version): + AdUtils.show("pkgs_installed" if pkgs_ok else "ros_installed") return True + + def print_summary(self, source_tool, expect, version, desktop, pkgs_ok): + """小白模式装完后列出帮用户选了什么; 没装成功时也列出来, 方便反馈问题时对照排查""" + ok = self.ros_tool.install_success(version) + PrintUtils.print_success("=" * 60) + if ok: + PrintUtils.print_success("安装完成! 小白模式帮你做了这些选择:") + else: + PrintUtils.print_warn("安装没有成功, 本次自动做的选择如下, 到社区反馈问题时请一起贴上:") + + source = getattr(source_tool, "current_source", None) + if source: + backups = [b for b, original in source_tool.backups if original == "/etc/apt/sources.list"] + if source_tool.use_deb822: + backups.append("/etc/apt/ubuntu.sources.fishros.bak") + PrintUtils.print_info(" 系统软件源: {} {} (测速最快)".format(source_tool.source_names.get(source, ""), source)) + if backups: + PrintUtils.print_info(" 原来的配置已备份到 {}".format(backups[0])) + else: + PrintUtils.print_info(" 系统软件源: 自动更换失败, 保留了原来的系统源") + + mirrors = getattr(self.ros_tool, "ros_mirrors", None) + PrintUtils.print_info(" ROS软件源: {} (测速最快, 且确认包能下载)".format(", ".join(mirrors) if mirrors else "添加失败")) + key = getattr(self.ros_tool, "key_source", None) + PrintUtils.print_info(" ROS公钥: {}".format("来自 {}, 已核对官方指纹".format(key) if key else "已通过备用方式导入")) + + reason = "{} {} 对应的推荐版本".format(osversion.get_name().capitalize(), osversion.get_version()) + if expect and version != expect: + reason = "推荐的 {} 当前无法安装, 改装了它".format(expect) + kind = "桌面版(带小乌龟、RViz 等图形工具)" if desktop else "基础版(磁盘剩余不足 {}GB, 没装图形工具)".format(min_free_gb_for_desktop) + PrintUtils.print_info(" ROS版本: {} {}, {}".format(RosVersions.get_version_string(version), kind, reason)) + + if pkgs_ok is None: + PrintUtils.print_info(" 常用功能包: 没有选择, 以后需要可以在菜单 [22] 安装(导航、建图、键盘/手柄遥控)") + else: + PrintUtils.print_info(" 常用功能包: 导航 Nav2、建图 slam_toolbox/Cartographer、键盘/手柄遥控等, {}".format( + "已全部装好" if pkgs_ok else "部分没装上, 可以在菜单 [22] 重试")) + + if ok: + PrintUtils.print_info(" 环境变量: 已写入 ~/.bashrc, 新开终端自动生效") + cmake = "/opt/ros/{}/share/rosidl_adapter/cmake/rosidl_adapt_interfaces.cmake".format(version) + if os.path.exists(cmake): + with open(cmake) as f: + fixed = "idl_tuples ENCODING UTF-8)" in f.read() + PrintUtils.print_info(" 中文路径: {}".format( + "已修复工作空间放在中文路径(如 ~/桌面)下编译消息包报错的问题" if fixed + else "修复没有生效, 工作空间请先放在英文路径下, 或运行菜单 [4] 重试")) + PrintUtils.print_success("=" * 60) diff --git a/tools/tool_install_ros_pkgs.py b/tools/tool_install_ros_pkgs.py index 87e7ca7..b1f29ea 100644 --- a/tools/tool_install_ros_pkgs.py +++ b/tools/tool_install_ros_pkgs.py @@ -2,7 +2,7 @@ import glob import os from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils from .tool_install_ros import RosVersions # ROS 桌面版不带导航、建图这些功能包, 新手装完 ROS 编译 FishBot 等机器人工作空间时最常缺的就是它们. @@ -120,11 +120,14 @@ def print_joy_tips(version): def run(self): versions = self.installed_ros() if not versions: - PrintUtils.print_error("当前系统还没有安装ROS, 请先使用一键安装 [21] 或 [1] 安装ROS") + PrintUtils.print_error("当前系统还没有安装ROS, 请先使用一键安装 [1] 安装ROS") return False version = versions[0] if len(versions) > 1: code, version = ChooseTask(versions, "检测到安装了多个ROS, 请选择要给哪个版本安装功能包:", True).run() if code == 0: return False - return self.install_common(version) + if not self.install_common(version): + AdUtils.mark_failed() + return False + AdUtils.show("pkgs_installed") diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 86c8df4..c417f2d 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -27,7 +27,10 @@ "开发工具": "Dev Tools", "日常软件": "Daily Apps", "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)": "Install ROS - Auto (for beginners: detects system, mirrors and version, one confirmation)", - "一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS - Custom (choose mirrors and version, ROS1/ROS2, Raspberry Pi/Jetson)", + "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS (recommended: beginner/expert modes, detects system and mirrors, ROS1/ROS2, Raspberry Pi/Jetson)", + "请选择安装模式(不知道怎么选就选1):": "Choose install mode (choose 1 if unsure):", + "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么": "Beginner (recommended): detects system, picks mirrors and version automatically, lists what was chosen at the end", + "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容": "Expert: choose system mirror, ROS mirror, ROS version and packages yourself", "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)": "Configure ROS environment (update shell setup, version switch, fix builds under non-ASCII paths)", @@ -48,7 +51,6 @@ "一键安装:QQ for Linux": "Install QQ for Linux", "请输入[]内的数字以选择:": "Type the number in [] to choose a function:", "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)": "Welcome to join the robot learning exchange QQ group (In Chinese): 438144612 (group password: 一键安装)", - "鱼香小铺正式开业,最低499可入手一台能建图会导航的移动机器人,淘宝搜店:鱼香ROS 或打开链接查看:https://item.taobao.com/item.htm?id=696573635888": "Online shop is now opened! You can get a mobile robot with SLAM and navigation for as low as 499 CNY. Search for '鱼香ROS' on Taobao or visit this link: https://item.taobao.com/item.htm?id=696573635888", """如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈""": """If you encounter any problems, please open: https://fishros.org.cn/forum for feedback""", "请选择你要测试的程序:": "Please select the program you want to test:", "为您从配置文件找到默认选项:": "Found default options from the configuration file:", From 85e5f81707924a4d6bef09fd76b8c4ffef573531 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:53:18 -0700 Subject: [PATCH 22/45] =?UTF-8?q?docs(readme):=20=E8=8F=9C=E5=8D=95=201=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=B0=8F=E7=99=BD/=E9=AB=98=E6=89=8B?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b90f44e..7246a7b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ 已支持工具列表(方括号里是菜单编号): **ROS安装** -- [21] 一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次) [贡献@小鱼](https://github.com/fishros) -- [1] 一键安装:ROS自定义版(高手适用,自选镜像源和版本,支持ROS1/ROS2/树莓派/Jetson) [贡献@小鱼](https://github.com/fishros) +- [1] 一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson) [贡献@小鱼](https://github.com/fishros) - [11] 一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用) - [16] 一键安装:系统自带ROS(仅供特殊情况下使用) @@ -45,8 +44,9 @@ source <(wget -qO- http://fishros.com/install) ``` -第一次装 ROS、不知道怎么选的,在菜单里输入 `21`(全自动安装ROS),确认后脚本会自动识别系统、 -测速换源、挑选可用的镜像和对应的 ROS 版本,中途不用再做选择。想自己指定镜像源和版本的,选 `1`。 +装 ROS 在菜单里输入 `1`,接着选安装模式:不知道怎么选的选 `1` 小白模式,确认后脚本会自动识别系统、 +测速换源、挑选可用的镜像和对应的 ROS 版本,中途不用再做选择,装完会列出替你选了什么; +想自己指定镜像源和版本的选 `2` 高手模式。 ### 用自己的服务器测试新版本 From 8ac334431ba8ec480c1dc717ed9e1eb53c2f397a Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:27:54 -0700 Subject: [PATCH 23/45] =?UTF-8?q?fix(source):=20=E6=B5=8B=E9=80=9F?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E8=B6=85=E6=97=B6=E5=86=8D=E8=AF=95=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8A=8A=E6=9C=80=E5=BF=AB?= =?UTF-8?q?=E7=9A=84=E9=95=9C=E5=83=8F=E8=AF=AF=E5=88=A4=E4=B8=BA=E8=BF=9E?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_system_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index 3c4cb6b..922e77d 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -250,6 +250,9 @@ def rank_mirrors(self, mirrors, codename): for name, url in mirrors: self.source_names[url] = name speed, status = self.measure_source(url, codename) + # 镜像偶尔会有一次连接超时(实测每轮都有一两个), 再试一次, 免得把最快的镜像误判掉 + if status == 'fail': + speed, status = self.measure_source(url, codename) if status == 'ok': speeds[url] = speed PrintUtils.print_success('- {}\t{}\t速度: {:.0f}KB/s'.format(name, url, speed)) From 2d2117032ce185daadb3f14201674c487bf4c419 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:51:29 -0700 Subject: [PATCH 24/45] =?UTF-8?q?feat(ros):=20=E5=B0=8F=E7=99=BD=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=A1=AE=E8=AE=A4=E8=8F=9C=E5=8D=95=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=99=84=E5=B8=A6=E5=B8=B8=E7=94=A8=E5=8A=9F=E8=83=BD=E5=8C=85?= =?UTF-8?q?=E7=9A=84=E9=80=89=E9=A1=B9,=20=E6=94=B9=E4=B8=BA=E8=A3=85?= =?UTF-8?q?=E5=AE=8C=E5=90=8E=E5=9C=A8=E6=B8=85=E5=8D=95=E9=87=8C=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=8F=9C=E5=8D=95=2022?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- install.py | 4 ++-- tests/fish_install_test.yaml | 7 ------- tools/tool_install_ros_auto.py | 23 +++++++---------------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/install.py b/install.py index 6d8b189..7148be7 100644 --- a/install.py +++ b/install.py @@ -30,14 +30,14 @@ "tip": "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)", "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros_auto.py", - "dep": [1, 4, 5, 22], + "dep": [1, 4, 5], "hidden": True, }, 1: { "tip": "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)", "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros.py", - "dep": [4, 5, 21, 22], + "dep": [4, 5, 21], }, 11: { "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)", diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index dde3252..d846aca 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -134,10 +134,3 @@ - { choose: 1, desc: "一键安装(推荐):ROS" } - { choose: 1, desc: "小白模式" } - { choose: 1, desc: "开始全自动安装" } - -- name: "Install_ROS_jammy_auto_with_pkgs" - target_os_version: "jammy" - chooses: - - { choose: 1, desc: "一键安装(推荐):ROS" } - - { choose: 1, desc: "小白模式" } - - { choose: 2, desc: "全自动安装 + 常用功能包(导航/建图)" } diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index bdad200..9b58f44 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -74,7 +74,6 @@ def show_plan(self, expect_version, desktop): PrintUtils.print_info(" 2. 添加ROS软件源: 自动测速选镜像, 遇到坏掉的镜像自动换下一个") PrintUtils.print_info(" 3. 安装 {}".format(ros_name)) PrintUtils.print_info(" 4. 配置环境变量, 新开终端就能直接使用ROS; 工作空间放在中文路径(如 ~/桌面)下也能编译") - PrintUtils.print_info(" (选 2 会再装上导航、建图等常用功能包, 也可以以后在菜单 [22] 单独装)") if desktop: PrintUtils.print_info("预计下载 0.5~1GB, 占用磁盘 3~4GB, 用时 10~40 分钟(取决于网速)") PrintUtils.print_success("=" * 60) @@ -112,16 +111,14 @@ def run(self): code, _ = ChooseTask( { 1: "开始全自动安装(推荐)", - 2: "全自动安装 + 常用功能包(导航/建图等, 做FishBot等机器人项目选这个, 多下载几百MB)", - 3: "我要自己选(进入高手自定义模式)", + 2: "我要自己选(进入高手自定义模式)", }, "请确认:", ).run() if code == 0: return False - if code == 3: + if code == 2: return self.ros_tool.install_ros() - with_pkgs = code == 2 # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 AptUtils.wait_and_repair() @@ -155,18 +152,15 @@ def run(self): break PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) self.ros_tool.install_ros_packages(version, install_code, dic_base) - pkgs_ok = None - if with_pkgs and self.ros_tool.install_success(version): - pkgs_ok = run_tool_file("tools.tool_install_ros_pkgs", authorun=False).install_common(version, auto=True) self.ros_tool.config_env_and_tip(version) if self.ros_tool.install_success(version): self.print_next_steps(version, desktop) - self.print_summary(source_tool, expect, version, desktop, pkgs_ok) + self.print_summary(source_tool, expect, version, desktop) if self.ros_tool.install_success(version): - AdUtils.show("pkgs_installed" if pkgs_ok else "ros_installed") + AdUtils.show("ros_installed") return True - def print_summary(self, source_tool, expect, version, desktop, pkgs_ok): + def print_summary(self, source_tool, expect, version, desktop): """小白模式装完后列出帮用户选了什么; 没装成功时也列出来, 方便反馈问题时对照排查""" ok = self.ros_tool.install_success(version) PrintUtils.print_success("=" * 60) @@ -197,11 +191,8 @@ def print_summary(self, source_tool, expect, version, desktop, pkgs_ok): kind = "桌面版(带小乌龟、RViz 等图形工具)" if desktop else "基础版(磁盘剩余不足 {}GB, 没装图形工具)".format(min_free_gb_for_desktop) PrintUtils.print_info(" ROS版本: {} {}, {}".format(RosVersions.get_version_string(version), kind, reason)) - if pkgs_ok is None: - PrintUtils.print_info(" 常用功能包: 没有选择, 以后需要可以在菜单 [22] 安装(导航、建图、键盘/手柄遥控)") - else: - PrintUtils.print_info(" 常用功能包: 导航 Nav2、建图 slam_toolbox/Cartographer、键盘/手柄遥控等, {}".format( - "已全部装好" if pkgs_ok else "部分没装上, 可以在菜单 [22] 重试")) + PrintUtils.print_info(" 常用功能包: 没有安装. 做 FishBot 等导航、建图项目时, 重新运行一键安装选菜单 [22] 安装") + PrintUtils.print_info(" (导航 Nav2、建图 slam_toolbox/Cartographer、键盘/手柄遥控等)") if ok: PrintUtils.print_info(" 环境变量: 已写入 ~/.bashrc, 新开终端自动生效") From 3056f0e0acff355e070ab8226e1c69f10eecdbe7 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:06:07 -0700 Subject: [PATCH 25/45] =?UTF-8?q?feat:=20[0]=20=E6=94=B9=E4=B8=BA=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E4=B8=8A=E4=B8=80=E6=AD=A5;=20=E9=AB=98=E6=89=8B?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=8D=A2=E6=BA=90=E5=89=8D=E5=85=88=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E5=BD=93=E5=89=8D=E7=B3=BB=E7=BB=9F=E6=BA=90=E5=B9=B6?= =?UTF-8?q?=E7=BB=99=E5=87=BA=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有选择里的 [0] 由 quit 改为"返回上一步": 多步流程退回前一个问题, 第一个问题退回上一级, 工具因此退出时回到主菜单; 只有主菜单的 [0] 是退出一键安装 - 菜单 1: 选模式 <- 小白确认 / 高手: 系统源 -> ROS镜像源 -> ROS版本 -> 桌面/基础版, 每步都能退回 - 菜单 5: 换源方式 -> 选择方式 -> 手动选镜像(退回时撤销本次换源) -> 是否添加ROS源 - 菜单 16 同样支持退回 - 修复高手模式"是否更换系统源"选 0 会当成"不更换"继续安装 - 高手模式和菜单 16 换源前检测当前系统源: 国外源建议选 1 换源, 国内源建议选 2 不换, 识别不了建议换 Co-Authored-By: Claude Opus 5.5 --- install.py | 31 ++-- tools/base.py | 17 +- tools/tool_config_system_source.py | 201 +++++++++++++++++------ tools/tool_install_ros.py | 147 ++++++++++------- tools/tool_install_ros1_systemdefault.py | 25 ++- tools/tool_install_ros_auto.py | 29 ++-- tools/translation/assets/en_US.py | 3 + 7 files changed, 308 insertions(+), 145 deletions(-) diff --git a/install.py b/install.py index 7148be7..9fc723e 100644 --- a/install.py +++ b/install.py @@ -205,6 +205,7 @@ def main(): ChooseWithCategoriesTask, Tracking, AdUtils, + BaseTool, ) from tools.base import encoding_utf8, osversion, osarch from tools.base import run_tool_file, download_tools @@ -266,20 +267,28 @@ def main(): PrintUtils.print_delay(tip, 0.001) PrintUtils.print_delay(book, 0.001) # download tools - code, result = ChooseWithCategoriesTask( - tool_categories, - tips=tr.tr("---众多工具,等君来用---"), - categories=tools_type_map, - ).run() - if code == 0: - PrintUtils().print_success( - tr.tr("是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~") - ) - else: + while True: + code, result = ChooseWithCategoriesTask( + tool_categories, + tips=tr.tr("---众多工具,等君来用---"), + categories=tools_type_map, + ).run() + if code == 0: + PrintUtils().print_success( + tr.tr("是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~") + ) + break if url_prefix: download_tools(code, tools, url_prefix) - if run_tool_file(tools[code]["tool"].replace("/", ".")) == False: + ChooseTask.last_back = False + ret = run_tool_file(tools[code]["tool"].replace("/", ".")) + # 在工具里选了 [0] 返回上一步退出来的, 回到主菜单重新选 + if ret == BaseTool.BACK or ChooseTask.last_back: + PrintUtils.print_info(tr.tr("已返回主菜单")) + continue + if ret == False: AdUtils.mark_failed() + break # 检查是否在 GitHub Actions 环境中运行或使用了测试配置文件 # 如果是,则跳过生成配置文件和后续的打印操作,因为这些操作需要用户输入 diff --git a/tools/base.py b/tools/base.py index c5dc4a0..6399a37 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1151,6 +1151,9 @@ def run(self): class ChooseTask(Task): + # 最近一次选择是不是 [0] 返回上一步. 工具因此退出时, install.py 会回到主菜单而不是结束一键安装 + last_back = False + def __init__(self, dic, tips, array=False) -> None: self.tips = tips self.dic = dic @@ -1167,8 +1170,8 @@ def __choose(data, tips, array): count += 1 else: dic = data - dic[0] = "quit" - # 0 quit + # 0 统一表示返回上一步: 多步流程退回前一个问题, 第一个问题退回上一级菜单 + dic[0] = "返回上一步" choose = -1 for key in dic: PrintUtils.print_delay("[{}]:{}".format(key, tr.tr(dic[key])), 0.005) @@ -1202,6 +1205,7 @@ def __choose(data, tips, array): choose = int(choose) break config_helper.record_choose({"choose": choose, "desc": dic[choose]}) + ChooseTask.last_back = choose == 0 PrintUtils.print_fish() return choose, dic[choose] @@ -1238,7 +1242,7 @@ def __choose(data, tips, array, categories): ) tool_ids.append(tool_id) print() - PrintUtils.print_delay("[0]:quit\n", 0.005) + PrintUtils.print_delay("[0]:{}\n".format(tr.tr("退出")), 0.005) choose = None choose_item = config_helper.get_input_value() @@ -1764,6 +1768,8 @@ class BaseTool: TYPE_INSTALL = 0 TYPE_UNINSTALL = 1 TYPE_CONFIG = 2 + # run() 或流程中的某一步返回它, 表示用户选了 [0] 返回上一步 + BACK = "back" def __init__(self, name, tool_type): self.osinfo = osversion @@ -1798,7 +1804,10 @@ def run_tool_file(file, authorun=True): return tool if tool.init() == False: return False - if tool.run() == False: + result = tool.run() + if result == BaseTool.BACK: + return BaseTool.BACK + if result == False: return False if tool.uninit() == False: return False diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index 922e77d..f1b9125 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- import os +import re import time import urllib.error import urllib.request from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask -from .base import osversion +from .base import osversion, tr from .base import run_tool_file +from urllib.parse import urlparse @@ -111,6 +113,24 @@ ("Debian官方", "http://deb.debian.org/debian"), ] +# 判断当前系统源是国内还是国外: 上面列表里的都认识, 另外补几个常见的; 其余以 .cn 结尾的域名也算国内 +domestic_hosts = { + "cn.archive.ubuntu.com": "Ubuntu官方中国镜像", + "mirrors.aliyun.com": "阿里云", + "mirrors.cloud.tencent.com": "腾讯云", + "mirrors.tencent.com": "腾讯云", + "mirrors.163.com": "网易", +} +foreign_hosts = { + "archive.ubuntu.com": "Ubuntu官方", + "security.ubuntu.com": "Ubuntu官方", + "ports.ubuntu.com": "Ubuntu官方", + "old-releases.ubuntu.com": "Ubuntu官方", + "deb.debian.org": "Debian官方", + "security.debian.org": "Debian官方", + "mirrors.kernel.org": "kernel.org", +} + class Tool(BaseTool): def __init__(self): @@ -125,14 +145,18 @@ def __init__(self): def add_ros_source(self): - """快速添加ROS源""" - dic = {1:"添加ROS/ROS2源",2:"不添加ROS/ROS2源"} - code,result = ChooseTask(dic, "请问是否添加ROS和ROS2源?").run() - if code!=1: return - tool = run_tool_file('tools.tool_install_ros',authorun=False) - if not tool.support_install(): return False - tool.add_key() - tool.add_source() + """快速添加ROS源. 选 [0] 返回 BaseTool.BACK""" + while True: + dic = {1:"添加ROS/ROS2源",2:"不添加ROS/ROS2源"} + code,result = ChooseTask(dic, "请问是否添加ROS和ROS2源?").run() + if code==0: return BaseTool.BACK + if code!=1: return + tool = run_tool_file('tools.tool_install_ros',authorun=False) + if not tool.support_install(): return False + tool.add_key() + # 在选择ROS镜像那一步返回上一步, 回到这个问题 + if tool.add_source() == BaseTool.BACK: continue + return def backup_path(self, path): """把要清理的源文件/目录改名备份, 而不是直接删除, 出问题可以手动恢复""" @@ -171,27 +195,37 @@ def restore_sources(self): self.backups = [] if getattr(self, 'original_deb822', None) is not None: FileUtils.new('/etc/apt/sources.list.d/', 'ubuntu.sources', self.original_deb822) - PrintUtils.print_warn('已取消换源, 系统源已恢复成原来的配置') - - def clean_old_source(self, auto=False): - """备份旧的源配置. 用户在提问里选了退出时返回 False, 这时还没有动任何文件""" - if auto: - # 全自动模式: 只换系统源, 第三方源先保留, apt update 失败时再挪走(见 change_sys_source) - code = 1 - self.source_method_code = 1 - else: - dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} - code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() - if code == 0: - return False + PrintUtils.print_warn('已撤销本次换源, 系统源恢复成原来的配置') + + def ask_source_options(self, step=0): + """高手模式换源前的两个问题, 选 [0] 退回前一个问题; 在第一个问题退回时返回 BaseTool.BACK""" + while True: + if step == 0: + dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} + self.clean_code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() + if self.clean_code == 0: + return BaseTool.BACK + step = 1 + continue PrintUtils.print_info("源选择方式说明:") PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() if self.source_method_code == 0: - return False + step = 0 + continue + return True + def clean_old_source(self, auto=False): + """备份旧的源配置: 改名备份而不是删除, 用户中途返回上一步时可以原样恢复""" + if auto: + # 全自动模式: 只换系统源, 第三方源先保留, apt update 失败时再挪走(见 apply_source) + code = 1 + self.source_method_code = 1 + else: + code = self.clean_code + self.backups = [] # 清理前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起挪走, 就判断不出来了 self.use_deb822 = os.path.exists(ubuntu_deb822_file) self.original_deb822 = None @@ -208,7 +242,69 @@ def clean_old_source(self, auto=False): PrintUtils.print_info('第三方源已备份到 {}, 如需恢复可把里面的文件挪回 /etc/apt/sources.list.d/'.format(backup_dir)) # fix add source failed before config system source CmdTask('sudo mkdir -p /etc/apt/sources.list.d').run() - return True + + def detect_current_source(self): + """ + 读出当前系统源的第一个地址, 判断是国内源还是国外源. + 返回 (地址, 名称, 'cn'/'foreign'/'unknown'), 读不到地址时地址为 None + """ + url = None + for path in (ubuntu_deb822_file, '/etc/apt/sources.list.d/debian.sources', '/etc/apt/sources.list'): + try: + with open(path, encoding='utf-8', errors='ignore') as f: + lines = f.read().splitlines() + except Exception: + continue + for line in lines: + m = re.match(r'\s*(?:URIs:\s*|deb\s+(?:\[[^\]]*\]\s+)?)(\S+)', line) + if m: + url = m.group(1) + break + if url: + break + if not url: + return None, '', 'unknown' + host = urlparse(url).netloc.lower() + names = {} + for name, mirror in ubuntu_mirrors + ubuntu_ports_mirrors + ubuntu_old_mirrors + debian_mirrors: + names[urlparse(mirror).netloc] = name + if host in domestic_hosts: + return url, domestic_hosts[host], 'cn' + if host in foreign_hosts or host.endswith('.archive.ubuntu.com'): + return url, foreign_hosts.get(host, 'Ubuntu官方'), 'foreign' + if host in names or host.endswith('.cn'): + return url, names.get(host, ''), 'cn' + return url, '', 'unknown' + + def ask_change_before_install(self): + """ + 装 ROS 前问要不要换系统源(高手模式第一步、菜单 16 都用它): 先检测当前系统源, 国外源建议换, 国内源建议不换. + 选 [0] 返回 BaseTool.BACK + """ + url, name, region = self.detect_current_source() + source = '{} {}'.format(name, url).strip() if url else '没有找到' + if tr is not None and hasattr(tr, 'isCN') and not tr.isCN(): + PrintUtils.print_info('检测到当前系统源: {}'.format(source)) + tips = '您不在中国大陆, 一般不用换, 建议选2' + elif region == 'cn': + PrintUtils.print_success('检测到当前系统源是国内源: {}'.format(source)) + tips = '当前已经是国内源, 一般不用换, 建议选2; 如果之前安装失败过或下载很慢, 可以选1重新测速换源' + elif region == 'foreign': + PrintUtils.print_warn('检测到当前系统源是国外源: {}'.format(source)) + tips = '国外源在国内下载慢、容易失败, 建议选1换成国内源' + else: + PrintUtils.print_warn('检测到当前系统源: {}, 无法判断是国内还是国外源'.format(source)) + tips = '如果不确定, 建议选1自动测速换成国内源' + dic = {1:"更换系统源再继续安装",2:"不更换继续安装"} + while True: + code,result = ChooseTask(dic, tips).run() + if code == 0: + return BaseTool.BACK + if code == 2: + return + # 换源时在第一个问题选了返回上一步, 回到这个问题 + if self.change_sys_source() != BaseTool.BACK: + return def get_debian_template(self, codename): if codename in ('wheezy', 'jessie', 'stretch', 'buster'): @@ -340,15 +436,38 @@ def replace_source(self,failed_sources=[]): def change_sys_source(self, auto=False): - self.cancelled = False + """ + 更换系统源. 高手模式先问两个问题, 手动选镜像时选 [0] 会撤销本次换源并退回前一个问题; + 在第一个问题选 [0] 时返回 BaseTool.BACK + """ self.current_source = None self.failed_sources = [] - if not self.clean_old_source(auto=auto): - self.cancelled = True - PrintUtils.print_warn('已取消换源, 系统源没有改动') + step = 0 + while True: + if not auto and self.ask_source_options(step) == BaseTool.BACK: + return BaseTool.BACK + self.cancelled = False + self.clean_old_source(auto=auto) + source, result = self.apply_source(auto) + if not self.cancelled: + break + # 手动选镜像时选了返回上一步: 撤销本次换源, 回到"源的选择方式"这个问题 + self.restore_sources() + step = 1 + + if not source: + PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") return + self.current_source = source if result[0] == 0 else None + if result[0]==0: + PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") + PrintUtils.print_info(result[1]) + PrintUtils.print_success("镜像更新完成.....") + def apply_source(self, auto=False): + """测速、写入镜像并更新索引, 更新失败时自动(手动模式下让用户)换下一个. 返回 (镜像地址, apt update 结果)""" failed_sources = [] + self.failed_sources = failed_sources result = (-1, [], []) source = self.replace_source(failed_sources) if source: @@ -370,20 +489,7 @@ def change_sys_source(self, auto=False): if not source: break result = CmdTask('sudo apt update',100).run() - - if self.cancelled: - self.restore_sources() - return - if not source: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") - return - - self.current_source = source if result[0] == 0 else None - self.failed_sources = failed_sources - if result[0]==0: - PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") - PrintUtils.print_info(result[1]) - PrintUtils.print_success("镜像更新完成.....") + return source, result def switch_to_next_source(self): """ @@ -409,8 +515,9 @@ def exclude_server(self, source): def run(self): # 正式的运行 - self.change_sys_source() - if self.cancelled: - return - # 添加 ROS Source - self.add_ros_source() + while True: + if self.change_sys_source() == BaseTool.BACK: + return BaseTool.BACK + # 在"是否添加ROS源"选了返回上一步: 回到换源的第一个问题重新选 + if self.add_ros_source() != BaseTool.BACK: + return diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 5e9b852..e4630b7 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -538,11 +538,19 @@ def get_mirror_by_code(self, code, arch="amd64", first_choose="tsinghua", exclud break return mirror + def supported_mirrors(self): + """当前系统可选的国内 ROS 镜像(按推荐顺序)""" + codename = osversion.get_codename() + return [ + mirror + for mirror in ("ustc", "tsinghua", "huawei", "mirrorz", "aliyun") + if mirror in ros_dist_dic.get(codename, ()) or mirror in ros2_dist_dic.get(codename, ()) + ] + def select_mirror(self): """ - 让用户选择镜像源 + 让用户选择镜像源, 选 [0] 返回 BaseTool.BACK """ - codename = osversion.get_codename() mirror_names = { "ustc": "中科大镜像源 (推荐国内用户使用)", "tsinghua": "清华镜像源 (容易被封禁)", @@ -550,12 +558,7 @@ def select_mirror(self): "mirrorz": "MirrorZ 校园网联合镜像站", "aliyun": "阿里云镜像源 (仅ROS2, Ubuntu20.04/18.04 装ROS2请选它)", } - supported_mirrors = [] - for mirror in mirror_names.keys(): - if mirror in ros_dist_dic.get(codename, ()) or mirror in ros2_dist_dic.get( - codename, () - ): - supported_mirrors.append(mirror) + supported_mirrors = self.supported_mirrors() # 只有官方源或只有一个国内源时不再询问 if len(supported_mirrors) <= 1: @@ -573,11 +576,11 @@ def select_mirror(self): mirror_dict, "检测到您的系统支持多个ROS镜像源,请选择您想要使用的ROS镜像源:", ).run() + if code == 0: + return BaseTool.BACK if code == official: return "packages.ros" - if code in mirror_dict: - return supported_mirrors[code - 1] - return supported_mirrors[0] + return supported_mirrors[code - 1] def write_ros_source(self, arch, codename, mirrors): source_data = "" @@ -678,6 +681,8 @@ def add_source(self, auto=False): else: # 让用户选择镜像源 selected_mirror = self.select_mirror() + if selected_mirror == BaseTool.BACK: + return BaseTool.BACK PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) excluded = {"ROS1": set(), "ROS2": set()} @@ -718,6 +723,8 @@ def add_source(self, auto=False): continue PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") selected_mirror = self.select_mirror() + if selected_mirror == BaseTool.BACK: + return BaseTool.BACK PrintUtils.print_info("您重新选择的镜像源: {}".format(selected_mirror)) for version in excluded.keys(): excluded[version].discard(selected_mirror) @@ -849,18 +856,9 @@ def add_key(self): return cmd_result def check_sys_source(self): - # 更换系统源 - dic = {1: "更换系统源再继续安装", 2: "不更换继续安装"} - PrintUtils.print_warn( - "=========接下来这一步很很很很重要,如果不知道怎么选请选择1========" - ) - code, result = ChooseTask( - dic, - "新手或首次安装一定要一定要一定要换源并清理三方源,换源!!!系统默认国外源容易失败!!", - ).run() - if code == 1: - tool = run_tool_file("tools.tool_config_system_source", authorun=False) - tool.change_sys_source() + """高手模式第一步: 问要不要换系统源(会先检测当前系统源是国内还是国外再给建议), 选 [0] 返回 BaseTool.BACK""" + tool = run_tool_file("tools.tool_config_system_source", authorun=False) + return tool.ask_change_before_install() def get_all_instsll_ros_pkgs(self): AptUtils.checkapt() @@ -905,13 +903,15 @@ def install_success(self, name): return True return False - def choose_and_install_ros(self): - # search ros packages + def choose_ros_version(self): + """ + 选择 ROS 版本. 返回 (版本名, dic_base); 选 [0] 返回 BaseTool.BACK; 软件源里搜不到 ROS 时返回 None + """ dic_base = AptUtils.search_package( "ros-base", "ros-[A-Za-z]+-ros-base", "ros-", "-base" ) if dic_base == None: - return False + return None ros_name = {} for a in dic_base.keys(): @@ -921,23 +921,23 @@ def choose_and_install_ros(self): ros_name.keys(), "请选择你要安装的ROS版本名称(请注意ROS1和ROS2区别):", True ).run() if code == 0: - PrintUtils.print_error("你选择退出") PrintUtils.print_delay( "是因为没有自己想要的ROS版本吗?ROS版本和操作系统版本是有对应关系的哦,所以可能是你的系统版本{}不对!具体请查看:https://fishros.org.cn/forum/topic/96".format( str(str(osversion.get_name()) + str(osversion.get_version())) - ) + ), + 0.001, ) - return False + return BaseTool.BACK + return ros_name[rosname], dic_base + + def choose_ros_kind(self, version): + """桌面版还是基础版, 返回 1(桌面版) / 2(基础版); 选 [0] 返回 BaseTool.BACK""" + rosname = RosVersions.get_version_string(version) version_dic = {1: rosname + "桌面版", 2: rosname + "基础版(小)"} code, name = ChooseTask( version_dic, "请选择安装的具体版本(如果不知道怎么选,请选1桌面版):", False ).run() - - if code == 0: - PrintUtils.print_error("你选择退出。。。。") - return False - - return self.install_ros_packages(ros_name[rosname], code, dic_base) + return BaseTool.BACK if code == 0 else code def install_ros_packages(self, install_version, code, dic_base): """ @@ -1116,34 +1116,67 @@ def config_env_and_tip(self, version_name): ) def install_ros(self): + """ + 高手模式: 依次选择 系统源 -> ROS镜像源 -> ROS版本 -> 桌面版/基础版, 然后安装. + 每一步选 [0] 都退回前一步, 在第一步退回时返回 BaseTool.BACK + """ PrintUtils.print_delay("欢迎使用ROS开箱子工具,本工具由[鱼香ROS]小鱼贡献..") if not self.support_install(): return False AptUtils.wait_and_repair() - self.check_sys_source() - self.add_key() - self.add_source() - - ros_version = self.choose_and_install_ros() - if not ros_version: - return - self.config_env_and_tip(ros_version) - if self.install_success(ros_version): - RosVersions.tip_test_command(ros_version) + step, forward, key_added = 0, True, False + version, dic_base, kind = None, None, None + while step < 4: + if step < 0: + return BaseTool.BACK + if step == 0: + result = self.check_sys_source() + elif step == 1: + # 只有一个可选镜像时这一步不提问, 从下一步退回来时直接再往前退, 否则会卡在两步之间 + if not forward and len(self.supported_mirrors()) <= 1: + step -= 1 + continue + if not key_added: + self.add_key() + key_added = True + result = self.add_source() + if result is False: + return False + elif step == 2: + result = self.choose_ros_version() + if result is None: + return False + if result != BaseTool.BACK: + version, dic_base = result + else: + result = self.choose_ros_kind(version) + kind = result + forward = result != BaseTool.BACK + step += 1 if forward else -1 + + self.install_ros_packages(version, kind, dic_base) + self.config_env_and_tip(version) + if self.install_success(version): + RosVersions.tip_test_command(version) AdUtils.show("ros_installed") def run(self): # 网上的教程都写"输入 1", 所以小白模式也放在菜单 1 里, 进来先选模式 - code, _ = ChooseTask( - { - 1: "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么", - 2: "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容", - }, - "请选择安装模式(不知道怎么选就选1):", - ).run() - if code == 0: - return False - if code == 1: - return run_tool_file("tools.tool_install_ros_auto", authorun=False).run() - self.install_ros() + while True: + code, _ = ChooseTask( + { + 1: "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么", + 2: "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容", + }, + "请选择安装模式(不知道怎么选就选1):", + ).run() + if code == 0: + return BaseTool.BACK + if code == 1: + result = run_tool_file("tools.tool_install_ros_auto", authorun=False).run() + else: + result = self.install_ros() + # 在模式里的第一步选了返回上一步, 回到选模式 + if result != BaseTool.BACK: + return result diff --git a/tools/tool_install_ros1_systemdefault.py b/tools/tool_install_ros1_systemdefault.py index ee7ab47..f6a4b90 100644 --- a/tools/tool_install_ros1_systemdefault.py +++ b/tools/tool_install_ros1_systemdefault.py @@ -12,27 +12,24 @@ def __init__(self): PrintUtils.print_warn("警告!!此工具安装的ROS与从ROS官方软件源安装的ROS冲突!") def run(self): - #正式的运行 - self.check_sys_source() - self.install_system_ros() - return True + #正式的运行: 每一步选 [0] 都退回前一步 + while True: + if self.check_sys_source() == BaseTool.BACK: + return BaseTool.BACK + if self.install_system_ros() != BaseTool.BACK: + return True def check_sys_source(self): - # 更换系统源 - dic = {1:"更换系统源再继续安装",2:"不更换继续安装"} - PrintUtils.print_warn("=========接下来这一步很很很很重要,如果不知道怎么选请选择1========") - code,result = ChooseTask(dic, "新手或首次安装一定要一定要一定要换源并清理三方源,换源!!!系统默认国外源容易失败!!").run() - if code==1: - tool = run_tool_file('tools.tool_config_system_source',authorun=False) - tool.change_sys_source() + # 更换系统源: 先检测当前系统源是国内还是国外再给建议, 选 [0] 返回 BaseTool.BACK + tool = run_tool_file('tools.tool_config_system_source',authorun=False) + return tool.ask_change_before_install() def install_system_ros(self): version_dic = {1:"完整版",2:"基础版(小)"} code,name = ChooseTask(version_dic,"请选择安装的具体版本(如果不知道怎么选,请选1桌面版):",False).run() - if code==0: - PrintUtils.print_error("你选择退出。。。。") - return False + if code==0: + return BaseTool.BACK install_tool = 'aptitude' install_tool_apt = 'apt' diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index 9b58f44..6a6d81f 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -107,18 +107,23 @@ def run(self): codename = osversion.get_codename() expect = recommend_ros.get(codename, [None])[0] - self.show_plan(expect, desktop) - code, _ = ChooseTask( - { - 1: "开始全自动安装(推荐)", - 2: "我要自己选(进入高手自定义模式)", - }, - "请确认:", - ).run() - if code == 0: - return False - if code == 2: - return self.ros_tool.install_ros() + while True: + self.show_plan(expect, desktop) + code, _ = ChooseTask( + { + 1: "开始全自动安装(推荐)", + 2: "我要自己选(进入高手自定义模式)", + }, + "请确认:", + ).run() + if code == 0: + return BaseTool.BACK + if code == 1: + break + # 高手模式第一步选了返回上一步, 回到这个确认界面 + result = self.ros_tool.install_ros() + if result != BaseTool.BACK: + return result # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 AptUtils.wait_and_repair() diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index c417f2d..0fd8d2b 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -29,6 +29,9 @@ "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)": "Install ROS - Auto (for beginners: detects system, mirrors and version, one confirmation)", "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS (recommended: beginner/expert modes, detects system and mirrors, ROS1/ROS2, Raspberry Pi/Jetson)", "请选择安装模式(不知道怎么选就选1):": "Choose install mode (choose 1 if unsure):", + "返回上一步": "Back", + "退出": "Quit", + "已返回主菜单": "Back to the main menu", "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么": "Beginner (recommended): detects system, picks mirrors and version automatically, lists what was chosen at the end", "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容": "Expert: choose system mirror, ROS mirror, ROS version and packages yourself", "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", From cb1953ea241c2c96629cc35b9322797114a76e06 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:08:31 -0700 Subject: [PATCH 26/45] =?UTF-8?q?fix(source):=20=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E8=85=BE=E8=AE=AF=E4=BA=91/=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=87=AA=E5=B8=A6=E7=9A=84=E5=86=85?= =?UTF-8?q?=E7=BD=91=E9=95=9C=E5=83=8F=E4=B8=BA=E5=9B=BD=E5=86=85=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/tool_config_system_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index f1b9125..0fadabe 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -119,6 +119,9 @@ "mirrors.aliyun.com": "阿里云", "mirrors.cloud.tencent.com": "腾讯云", "mirrors.tencent.com": "腾讯云", + # 云服务器自带的内网镜像 + "mirrors.tencentyun.com": "腾讯云内网镜像", + "mirrors.cloud.aliyuncs.com": "阿里云内网镜像", "mirrors.163.com": "网易", } foreign_hosts = { From 39e26945cd53a4e98634a69ad23adf579336816d Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:38:07 -0700 Subject: [PATCH 27/45] =?UTF-8?q?feat(ads):=20=E8=8F=9C=E5=8D=95=E5=89=8D?= =?UTF-8?q?=E5=8A=A0=E5=95=86=E5=93=81=E9=93=BE=E6=8E=A5=E5=92=8C=E5=85=AC?= =?UTF-8?q?=E4=BC=97=E5=8F=B7=E6=8F=90=E7=A4=BA;=20=E8=A3=85=E5=A5=BD?= =?UTF-8?q?=E5=90=8E=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=94=B9=E4=B8=BA=E6=B7=98?= =?UTF-8?q?=E5=AE=9D+=E5=85=AC=E4=BC=97=E5=8F=B7=E5=B9=B6=E6=8E=92?= =?UTF-8?q?=E5=B9=B6=E7=BC=A9=E5=B0=8F;=20=E8=8F=9C=E5=8D=95=20[1]=20?= =?UTF-8?q?=E7=94=A8=E9=86=92=E7=9B=AE=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- install.py | 3 +++ tools/base.py | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/install.py b/install.py index 9fc723e..c59801f 100644 --- a/install.py +++ b/install.py @@ -38,6 +38,7 @@ "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros.py", "dep": [4, 5, 21], + "highlight": True, }, 11: { "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)", @@ -264,6 +265,8 @@ def main(): 如果觉得工具好用,请给个star,如果你想和小鱼一起编写工具,请关注B站/公众号<鱼香ROS>,联系小鱼 更多工具教程,请访问鱼香ROS官方网站:http://fishros.com """) + # 菜单最上面先放一行商品链接和公众号提示(不放二维码), 内容来自服务器的 ads.json + AdUtils.show("welcome") PrintUtils.print_delay(tip, 0.001) PrintUtils.print_delay(book, 0.001) # download tools diff --git a/tools/base.py b/tools/base.py index 6399a37..02dd7a0 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1236,10 +1236,11 @@ def __choose(data, tips, array, categories): PrintUtils.print_delay("{}:".format(tr.tr(categories[tool_type])), 0.005) # 按 install.py 里的书写顺序展示, 方便把推荐项排在前面 for tool_id in tools_list.keys(): - PrintUtils.print_delay( - " [{:>2}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])), - 0.005, - ) + item = " [{:>2}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])) + if tools_list[tool_id].get("highlight"): + # 推荐项用加粗的亮黄色, 一眼就能看到 + item = "\033[1;93m{}\033[0m".format(item) + PrintUtils.print_delay(item, 0.005) tool_ids.append(tool_id) print() PrintUtils.print_delay("[0]:{}\n".format(tr.tr("退出")), 0.005) @@ -1664,7 +1665,13 @@ class AdUtils: """ shop_url = "https://item.taobao.com/item.htm?abbucket=0&id=1083795293714" + mp_line = "关注微信公众号「鱼香ROS」, 获取 ROS 教程、答疑和新品消息" default_scenes = { + # 刚运行一键安装、菜单出现之前, 只放文字 + "welcome": { + "shop": True, + "lines": ["鱼香ROS小铺: 能建图、会导航的 ROS 机器人, 淘宝搜店「鱼香ROS」或打开: " + shop_url, mp_line], + }, # 开始下载安装 ROS, 要等好几分钟 "installing": { "shop": True, @@ -1677,6 +1684,7 @@ class AdUtils: "lines": [ "ROS 装好了, 想让它跑在真机器人上? 小铺有能建图、会导航的 ROS 机器人", "淘宝搜店「鱼香ROS」或打开: " + shop_url, + mp_line, ], }, # 导航/建图等常用功能包装好了 @@ -1686,6 +1694,7 @@ class AdUtils: "lines": [ "导航、建图功能包已就绪, 配一台能建图会导航的机器人就能上手实战", "淘宝搜店「鱼香ROS」或打开: " + shop_url, + mp_line, ], }, # 程序结束, 本次没展示过商品推广时补一条 @@ -1697,6 +1706,7 @@ class AdUtils: "finish": { "lines": [ "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)", + mp_line, "如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈", ], "lines_en": [ From 1da46b19b340125410d2b90a1dec199ae1413459 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:40:01 -0700 Subject: [PATCH 28/45] =?UTF-8?q?feat(ads):=20=E6=8E=A8=E5=B9=BF=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=94=B9=E8=AF=BB=E5=AE=98=E7=BD=91=20/api/install/ad?= =?UTF-8?q?s;=20=E5=90=AF=E5=8A=A8=E6=97=B6=E4=B8=8A=E6=8A=A5=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=AC=A1=E6=95=B0=E5=88=B0=E5=AE=98=E7=BD=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdUtils 改读 {FISHROS_API_URL 默认 http://fishros.com/api}/install/ads, 读不到仍用内置内容 - 新增 AdUtils.report_run(): 后台线程请求 /api/install/stat, 3 秒超时、失败忽略, GITHUB_ACTIONS=true 时不发 - 论坛 1733 帖子的访问保留 Co-Authored-By: Claude Opus 5.5 --- install.py | 7 ++++--- tools/base.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/install.py b/install.py index c59801f..5584519 100644 --- a/install.py +++ b/install.py @@ -219,7 +219,8 @@ def main(): global tracing tracing = copy.copy(Tracking) - # 使用量统计 + # 使用量统计: 官网按天计运行次数(后台线程, 失败忽略), 论坛 1733 帖子的访问照旧保留 + AdUtils.report_run() CmdTask( "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 && rm -rf /tmp/t1733" ).run() @@ -265,7 +266,7 @@ def main(): 如果觉得工具好用,请给个star,如果你想和小鱼一起编写工具,请关注B站/公众号<鱼香ROS>,联系小鱼 更多工具教程,请访问鱼香ROS官方网站:http://fishros.com """) - # 菜单最上面先放一行商品链接和公众号提示(不放二维码), 内容来自服务器的 ads.json + # 菜单最上面先放一行商品链接和公众号提示(不放二维码), 内容来自官网 /api/install/ads AdUtils.show("welcome") PrintUtils.print_delay(tip, 0.001) PrintUtils.print_delay(book, 0.001) @@ -300,7 +301,7 @@ def main(): and os.environ.get("FISH_INSTALL_CONFIG") is None ): config_helper.gen_config_file() - # 推广内容从服务器的 ads.json 读取, 见 tools/base.py 的 AdUtils + # 推广内容从官网 /api/install/ads 读取, 见 tools/base.py 的 AdUtils AdUtils.show("finish_shop") AdUtils.show("finish") diff --git a/tools/base.py b/tools/base.py index 02dd7a0..0d14bbe 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1659,9 +1659,10 @@ def get_fast_url(urls, timeout=1.5): class AdUtils: """ - 一键安装里的推广信息. 内容从服务器上的 ads.json(和 install.py 同目录)读取, 改文案、换商品不用改代码, + 一键安装里的推广信息. 内容从官网接口 /api/install/ads 读取(官网后台「一键安装」页可改商品链接、QQ 群等), 读不到时用下面的内置内容. 按场景展示, 每个场景一次运行只展示一次; 安装失败后不再展示商品推广. - ads.json 格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "qr": [...], "shop": true}}} + 接口返回格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "qr": [...], "shop": true}}} + 测试时可用环境变量 FISHROS_API_URL 指向别的官网地址, 默认 http://fishros.com/api """ shop_url = "https://item.taobao.com/item.htm?abbucket=0&id=1083795293714" @@ -1719,17 +1720,41 @@ class AdUtils: shown_scenes = set() failed = False + @staticmethod + def api_url(path): + return os.environ.get("FISHROS_API_URL", "http://fishros.com/api").rstrip("/") + path + + @classmethod + def report_run(cls): + """ + 官网按天统计一键安装的运行次数, 只发一个不带任何信息的请求. 放后台线程, 3 秒超时, + 失败就忽略, 不拖慢也不打断安装; GitHub Actions 自动测试不计入 + """ + if os.environ.get("GITHUB_ACTIONS") == "true": + return + + def send(): + try: + import urllib.request + + urllib.request.urlopen(cls.api_url("/install/stat"), timeout=3).close() + except Exception: + pass + + thread = threading.Thread(target=send) + thread.daemon = True + thread.start() + @classmethod def load(cls): if cls.scenes is not None: return cls.scenes cls.scenes = dict(cls.default_scenes) - url = os.path.join(os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/"), "ads.json") try: import json import urllib.request - with urllib.request.urlopen(url, timeout=3) as resp: + with urllib.request.urlopen(cls.api_url("/install/ads"), timeout=3) as resp: cls.scenes.update(json.loads(resp.read().decode("utf-8")).get("scenes", {})) except Exception: pass From b36da2159ab1fdb724c81b4670cf7d944e9a76f7 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:51:49 -0700 Subject: [PATCH 29/45] =?UTF-8?q?feat:=20=E6=8C=89=E5=AF=BC=E5=B8=88?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E4=B8=8A=E6=8A=A5=E7=BB=9F=E8=AE=A1;=20?= =?UTF-8?q?=E6=8E=A8=E5=B9=BF=E5=8F=AA=E6=94=BE=E6=B7=98=E5=AE=9D=E7=9B=B4?= =?UTF-8?q?=E9=93=BE;=20=E4=B8=8B=E8=BD=BD=E5=8A=A0=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 StatUtils: run 启动、menu:<编号> 主菜单选择、菜单 [1] 小白/高手的 pick/start/ok/fail(失败带系统代号), 后台线程 3 秒超时、失败忽略, 退出前最多等 2 秒; GitHub Actions 和 FISH_INSTALL_CONFIG 自动测试不上报 - 推广内容读官网 /api/install/ads(支持 gzip), 不再显示二维码; 推广文字里的链接改淡蓝色加下划线 - 安装成功提示「恭喜你,安装成功了…」改为淡黄色(PrintUtils.print_highlight) - 下载脚本文件的 wget 加 -T 15 -t 3(原来无超时, 网络一卡要等 900 秒) Co-Authored-By: Claude Opus 5.5 --- install | 2 +- install.py | 13 ++-- tools/base.py | 111 ++++++++++++++++++++++---------- tools/tool_install_ros.py | 8 ++- tools/tool_install_ros_auto.py | 7 +- tools/translation/translator.py | 2 +- 6 files changed, 99 insertions(+), 44 deletions(-) diff --git a/install b/install index a513e12..d8ea820 100644 --- a/install +++ b/install @@ -10,7 +10,7 @@ fi $SUDO rm -rf /tmp/fishinstall/ mkdir -p /tmp/fishinstall/tools/translation/assets # 与 install.py 里其它下载保持一致不校验证书: 老系统证书库过旧、树莓派等开机时钟不准时, 服务器跳转到 https 也能下载 -wget -q --no-check-certificate $FISHROS_URL/install.py -O /tmp/fishinstall/install.py +wget -q -T 15 -t 3 --no-check-certificate $FISHROS_URL/install.py -O /tmp/fishinstall/install.py source /etc/profile if [ -s /tmp/fishinstall/install.py ]; then diff --git a/install.py b/install.py index 5584519..e760e84 100644 --- a/install.py +++ b/install.py @@ -188,12 +188,12 @@ def main(): url_prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") if url_prefix: os.system( - "wget {} -O /tmp/fishinstall/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( base_url, base_url.replace(url_prefix, "") ) ) os.system( - "wget {} -O /tmp/fishinstall/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( translator_url, translator_url.replace(url_prefix, "") ) ) @@ -206,6 +206,7 @@ def main(): ChooseWithCategoriesTask, Tracking, AdUtils, + StatUtils, BaseTool, ) from tools.base import encoding_utf8, osversion, osarch @@ -219,10 +220,10 @@ def main(): global tracing tracing = copy.copy(Tracking) - # 使用量统计: 官网按天计运行次数(后台线程, 失败忽略), 论坛 1733 帖子的访问照旧保留 - AdUtils.report_run() + # 使用量统计: 官网按天计启动次数(后台线程, 失败忽略), 论坛 1733 帖子的访问照旧保留 + StatUtils.report("run") CmdTask( - "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 && rm -rf /tmp/t1733" + "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 -t 2 && rm -rf /tmp/t1733" ).run() PrintUtils.print_success(tr.tr("已为您切换语言至当前所在国家语言:") + tr.lang) @@ -282,6 +283,7 @@ def main(): tr.tr("是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~") ) break + StatUtils.report("menu:{}".format(code)) if url_prefix: download_tools(code, tools, url_prefix) ChooseTask.last_back = False @@ -304,6 +306,7 @@ def main(): # 推广内容从官网 /api/install/ads 读取, 见 tools/base.py 的 AdUtils AdUtils.show("finish_shop") AdUtils.show("finish") + StatUtils.flush() def ensure_utf8(): diff --git a/tools/base.py b/tools/base.py index 0d14bbe..c21f38b 100644 --- a/tools/base.py +++ b/tools/base.py @@ -913,6 +913,11 @@ def print_success(data, end="\n"): def print_warn(data, end="\n"): PrintUtils.print_text("\033[33m{}\033[37m".format(data), end=end) + @staticmethod + def print_highlight(data, end="\n"): + # 淡黄色(256 色里的 229), 用于「安装成功」这类要让用户一眼看到的好消息 + PrintUtils.print_text("\033[38;5;229m{}\033[37m".format(data), end=end) + @staticmethod def print_text(values="", end="\n", flush=False): print(values, end=str(end), flush=flush) # force to string @@ -1661,7 +1666,8 @@ class AdUtils: """ 一键安装里的推广信息. 内容从官网接口 /api/install/ads 读取(官网后台「一键安装」页可改商品链接、QQ 群等), 读不到时用下面的内置内容. 按场景展示, 每个场景一次运行只展示一次; 安装失败后不再展示商品推广. - 接口返回格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "qr": [...], "shop": true}}} + 接口返回格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "shop": true}}} + 10-28 导师定不放二维码(终端里画出来太大), 只放链接, 链接用淡蓝色加下划线, 和前后文字区分开 测试时可用环境变量 FISHROS_API_URL 指向别的官网地址, 默认 http://fishros.com/api """ @@ -1724,38 +1730,23 @@ class AdUtils: def api_url(path): return os.environ.get("FISHROS_API_URL", "http://fishros.com/api").rstrip("/") + path - @classmethod - def report_run(cls): - """ - 官网按天统计一键安装的运行次数, 只发一个不带任何信息的请求. 放后台线程, 3 秒超时, - 失败就忽略, 不拖慢也不打断安装; GitHub Actions 自动测试不计入 - """ - if os.environ.get("GITHUB_ACTIONS") == "true": - return - - def send(): - try: - import urllib.request - - urllib.request.urlopen(cls.api_url("/install/stat"), timeout=3).close() - except Exception: - pass - - thread = threading.Thread(target=send) - thread.daemon = True - thread.start() - @classmethod def load(cls): if cls.scenes is not None: return cls.scenes cls.scenes = dict(cls.default_scenes) try: + import gzip import json import urllib.request - with urllib.request.urlopen(cls.api_url("/install/ads"), timeout=3) as resp: - cls.scenes.update(json.loads(resp.read().decode("utf-8")).get("scenes", {})) + # 二维码的颜色转义很占地方, 压缩后从约 48KB 变成 2KB 不到, 网差时 3 秒内也能读完 + req = urllib.request.Request(cls.api_url("/install/ads"), headers={"Accept-Encoding": "gzip"}) + with urllib.request.urlopen(req, timeout=3) as resp: + data = resp.read() + if resp.headers.get("Content-Encoding") == "gzip": + data = gzip.decompress(data) + cls.scenes.update(json.loads(data.decode("utf-8")).get("scenes", {})) except Exception: pass return cls.scenes @@ -1785,13 +1776,65 @@ def show(cls, scene): if ad.get("title"): PrintUtils.print_success("-" * 20 + " {} ".format(ad["title"]) + "-" * 20) for line in lines: - PrintUtils.print_success(line) - if chinese and ad.get("qr"): - # 二维码自带黑白配色, 深色/浅色终端都能扫 - for row in ad["qr"]: - PrintUtils.print_text(row) - if ad.get("qr_tip"): - PrintUtils.print_info(ad["qr_tip"]) + PrintUtils.print_success(cls.color_links(line)) + + @staticmethod + def color_links(line): + # 链接改成淡蓝色(256 色里的 117)加下划线, 链接后面的文字恢复成 print_success 的绿色 + return re.sub(r"(https?://[^\s,。]+)", "\033[4;38;5;117m\\1\033[0;32m", line) + + +class StatUtils: + """ + 官网按天匿名计数: 每个事件只发一个 /install/stat?e=<事件> 请求, 服务器只记 日期+事件 的次数. + 事件: run(启动) / menu:<编号>(主菜单选择) / ros::(菜单 [1] 安装 ROS). + 放后台线程, 3 秒超时, 失败就忽略, 不拖慢也不打断安装; + GitHub Actions 和用配置文件(FISH_INSTALL_CONFIG)自动跑的测试不计入 + """ + + threads = [] + + @staticmethod + def enabled(): + return os.environ.get("GITHUB_ACTIONS") != "true" and os.environ.get("FISH_INSTALL_CONFIG") is None + + @classmethod + def report(cls, event): + if not cls.enabled(): + return + + def send(): + try: + import urllib.request + + urllib.request.urlopen(AdUtils.api_url("/install/stat?e=" + event), timeout=3).close() + except Exception: + pass + + thread = threading.Thread(target=send) + thread.daemon = True + thread.start() + cls.threads.append(thread) + + @classmethod + def flush(cls, timeout=2): + """程序结束前最多再等 timeout 秒, 让最后才发的(装成功/失败)统计发出去; 平时早就发完了, 不用等""" + deadline = time.time() + timeout + for thread in cls.threads: + thread.join(max(0, deadline - time.time())) + + @staticmethod + def system(): + """失败按系统代号(jammy、focal 等)分开计数, 取不到时记 unknown""" + try: + codename = re.sub("[^a-z0-9]", "", str(osversion.get_codename()).lower())[:20] + except Exception: + codename = "" + return codename or "unknown" + + @classmethod + def report_ros_result(cls, mode, ok): + cls.report("ros:{}:ok".format(mode) if ok else "ros:{}:fail:{}".format(mode, cls.system())) """ @@ -1851,7 +1894,7 @@ def run_tool_file(file, authorun=True): def run_tool_url(url, url_prefix): CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() @@ -1863,7 +1906,7 @@ def download_tools(id, tools, url_prefix): url = tools[id]["tool"] url = os.path.join(url_prefix, url) CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() @@ -1872,7 +1915,7 @@ def download_tools(id, tools, url_prefix): url = tools[dep]["tool"] url = os.path.join(url_prefix, url) CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index e4630b7..c78bafc 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -2,7 +2,7 @@ import os import re from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils, StatUtils from .base import osversion from .base import run_tool_file from .base import tr @@ -1106,7 +1106,7 @@ def config_env_and_tip(self, version_name): """ if self.install_success(version_name): run_tool_file("tools.tool_config_rosenv") - PrintUtils.print_success( + PrintUtils.print_highlight( "恭喜你,安装成功了,再附赠你机器人学习宝藏网站:鱼香社区:https://fishros.org.cn/forum" ) else: @@ -1155,8 +1155,11 @@ def install_ros(self): forward = result != BaseTool.BACK step += 1 if forward else -1 + # 统计: 选完开始装算一次「开始安装」, 装完按 install_success 记成功/失败(小白模式里转自定义的也算这里) + StatUtils.report("ros:expert:start") self.install_ros_packages(version, kind, dic_base) self.config_env_and_tip(version) + StatUtils.report_ros_result("expert", self.install_success(version)) if self.install_success(version): RosVersions.tip_test_command(version) AdUtils.show("ros_installed") @@ -1173,6 +1176,7 @@ def run(self): ).run() if code == 0: return BaseTool.BACK + StatUtils.report("ros:novice:pick" if code == 1 else "ros:expert:pick") if code == 1: result = run_tool_file("tools.tool_install_ros_auto", authorun=False).run() else: diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py index 6a6d81f..1e94fa1 100644 --- a/tools/tool_install_ros_auto.py +++ b/tools/tool_install_ros_auto.py @@ -3,7 +3,7 @@ import shutil from urllib.parse import urlparse from .base import BaseTool -from .base import PrintUtils, AptUtils, ChooseTask, AdUtils +from .base import PrintUtils, AptUtils, ChooseTask, AdUtils, StatUtils from .base import osversion from .base import run_tool_file from .tool_install_ros import RosVersions @@ -125,6 +125,8 @@ def run(self): if result != BaseTool.BACK: return result + # 统计: 确认开始算一次「开始安装」, 之后中途报错退出和装完检测不到都记失败 + StatUtils.report("ros:novice:start") # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 AptUtils.wait_and_repair() source_tool = run_tool_file("tools.tool_config_system_source", authorun=False) @@ -132,11 +134,13 @@ def run(self): self.ros_tool.add_key() if not self.ros_tool.add_source(auto=True): + StatUtils.report_ros_result("novice", False) return False dic_base = AptUtils.search_package("ros-base", "ros-[A-Za-z]+-ros-base", "ros-", "-base") if not dic_base: PrintUtils.print_error("软件源里没有找到可安装的ROS, 请携带 /tmp/fishros_install.log 到鱼香社区反馈") + StatUtils.report_ros_result("novice", False) return False version = self.pick_version(codename, list(dic_base.keys())) if expect and version != expect: @@ -158,6 +162,7 @@ def run(self): PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) self.ros_tool.install_ros_packages(version, install_code, dic_base) self.ros_tool.config_env_and_tip(version) + StatUtils.report_ros_result("novice", self.ros_tool.install_success(version)) if self.ros_tool.install_success(version): self.print_next_steps(version, desktop) self.print_summary(source_tool, expect, version, desktop) diff --git a/tools/translation/translator.py b/tools/translation/translator.py index 0bee0a8..3300489 100644 --- a/tools/translation/translator.py +++ b/tools/translation/translator.py @@ -41,7 +41,7 @@ def __init__(self): # Use /tmp/ directory directly to avoid permission issues temp_file = "/tmp/fishros_lang_{}.py".format(lang) final_path = "/tmp/fishinstall/{}".format(lang_url.format(lang).replace(url_prefix, '')) - download_cmd = "wget {} -O {} --no-check-certificate".format(lang_url.format(lang), temp_file) + download_cmd = "wget {} -O {} -T 15 -t 3 --no-check-certificate".format(lang_url.format(lang), temp_file) result = CmdTask(download_cmd).run() # Move file to final destination if download was successful if result[0] == 0: From 3168db29489210ac6848d295fb9d8a9f7957c58e Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:51:49 -0700 Subject: [PATCH 30/45] =?UTF-8?q?fix(wechat):=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E7=89=88=E8=A3=85=E5=89=8D=E5=A4=84=E7=90=86=E6=97=A7=E7=89=88?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=B9=B6=E6=8C=89=E7=9C=9F=E5=AE=9E=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=8F=90=E7=A4=BA;=20=E4=B8=8B=E7=BA=BF=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E7=89=88=202.1.1;=20=E6=94=B9=E8=BF=9B=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 官方 Linux 版: 按 uname -m 选 x86_64/arm64/LoongArch 安装包; dpkg 报文件冲突(如社区帖 4843 的旧版 wechat-beta)时说明是哪个旧包、确认后卸载重装; dpkg-query 确认装上才提示成功, 不再假成功 - 下线 [2] 桌面版 v2.1.1: gitee 源已失效, 且该包会把 /etc/lsb-release 换成麒麟、卸载脚本会出错; 编号 3、4 不变 - [4] 改名「一键清理微信」: 先检查再清理, 安全卸载旧桌面版并恢复 lsb-release, 列出清理了什么, 提醒聊天记录未删除; 清理完回到微信菜单 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_wechat.py | 168 ++++++++++++++++++++++++++++------- 1 file changed, 136 insertions(+), 32 deletions(-) diff --git a/tools/tool_install_wechat.py b/tools/tool_install_wechat.py index d017855..8121bbb 100644 --- a/tools/tool_install_wechat.py +++ b/tools/tool_install_wechat.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import os +import platform +import re +import shutil from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,ChooseTask from .base import osversion,osarch from .base import run_tool_file @@ -27,31 +31,15 @@ def get_wechat_scripts(self,name,build): def install_wechat(self): """ - # 桌面版微信 - https://archive.ubuntukylin.com/software/pool/partner/weixin_2.1.1_amd64.deb - git clone https://gitee.com/ohhuo/wechat_deb.git /tmp/wechat_deb - cd /tmp/wechat_deb && cat wechat_* > wechat.deb - cd /tmp/wechat_deb && sudo dpkg -i wechat.deb - rm -rf /tmp/wechat_deb + # 原来的 [2] 桌面版本(v2.1.1, 优麒麟打包的 weixin) 10-28 下线: 装上会把 /etc/lsb-release 换成麒麟的 + # (直接读这个文件的程序会把系统认成麒麟), 卸载脚本还会出错; 官方 Linux 版早已可用. + # 编号 3、4 不变, 免得网上教程和用户的配置文件对不上 # https://fishros.org.cn/forum/topic/195 - # sudo apt remove wechat-linux-spark - """ - wechat_version_dic = {1:"Docker版本",2:"桌面版本(v2.1.1)",3:"墙裂推荐:官方Linux版本",4:"一键清理"} + wechat_version_dic = {1:"Docker版本",3:"墙裂推荐:官方Linux版本",4:"一键清理微信"} code,_ = ChooseTask(wechat_version_dic,"请选择微信版本(两个版本区别对比:https://fishros.org.cn/forum/topic/195):",False).run() - if code==2: - AptUtils.install_pkg("git") - CmdTask('sudo apt install git',os_command=True).run() - CmdTask('git clone https://gitee.com/ohhuo/wechat_deb.git /tmp/wechat_deb',os_command=True).run() - CmdTask('cd /tmp/wechat_deb && cat wechat_* > wechat.deb',os_command=True).run() - CmdTask('cd /tmp/wechat_deb && sudo dpkg -i wechat.deb',os_command=True).run() - CmdTask('rm -rf /tmp/wechat_deb',os_command=True).run() - PrintUtils.print_success("已为你安装完成wechat~") if code==3: - CmdTask('wget https://dldir1v6.qq.com/weixin/Universal/Linux/WeChatLinux_x86_64.deb -O /tmp/wechat.deb',os_command=True).run() - CmdTask('sudo dpkg -i /tmp/wechat.deb',os_command=True).run() - CmdTask('apt --fix-broken install -y',os_command=True).run() - PrintUtils.print_success("已为你安装完成wechat-linux版本~") + return self.install_official() if code == 1: run_tool_file('tools.tool_install_docker') user = FileUtils.getusers()[0] @@ -89,17 +77,133 @@ def install_wechat(self): PrintUtils.print_info("=================分辨率/目录等配置==============") PrintUtils.print_warn("在任意终端输入wechat,选w-回车进入配置页面") if code==4: - try: - CmdTask("sudo apt remove wechat-linux-spark -y",os_command=True).run() - CmdTask("sudo apt remove wechat-* -y",os_command=True).run() - CmdTask("sudo docker stop wechat",os_command=True).run() - CmdTask("sudo docker rm wechat",os_command=True).run() - CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() - CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() - except: - pass + # 清理完回到微信菜单: 常常是清理后接着装官方版, 不装了选 0 回主菜单 + self.clean_wechat() + return self.install_wechat() + + # 一键清理时认得的微信包, 其它名字以 wechat 开头的包按包名显示 + wechat_pkg_names = { + "wechat": "官方 Linux 版微信", + "wechat-beta": "旧版微信 wechat-beta", + "wechat-linux-spark": "星火商店版微信", + } + + def clean_wechat(self): + """ + 一键清理微信: 先查有哪些, 只清理真正装了的(原来不管有没有都跑一遍 apt/docker, 满屏报错也不说清了什么), + 最后列出清理了什么. 主目录里的聊天记录和文件不删 + """ + cleaned = [] + if self.remove_desktop(): + cleaned.append("已下线的桌面版微信 v2.1.1(weixin)") + + result = CmdTask("dpkg-query -W -f='${Package}\\t${Status}\\n' 'wechat*'").run() + pkgs = [line.split("\t")[0] for line in result[1] + if "\t" in line and ("installed" in line or "config-files" in line) and "not-installed" not in line] + if pkgs: + CmdTask("sudo apt purge -y {}".format(" ".join(pkgs)),os_command=True).run() + cleaned += [self.wechat_pkg_names.get(p, p) for p in pkgs if not self.installed(p)] + + if shutil.which("docker"): + if "wechat" in "".join(CmdTask("sudo docker ps -a --filter name=^wechat$ --format '{{.Names}}'").run()[1]): + CmdTask("sudo docker rm -f wechat",os_command=True).run() + cleaned.append("Docker 版微信容器(wechat)") + if CmdTask("sudo docker image inspect bestwu/wechat").run()[0] == 0: + CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() + cleaned.append("Docker 版微信镜像(bestwu/wechat)") + + PrintUtils.print_info("=" * 50) + if not cleaned: + PrintUtils.print_success("没有发现已安装的微信, 不需要清理") + return True + PrintUtils.print_success("微信清理完成, 共清理了:") + for item in cleaned: + PrintUtils.print_success(" - " + item) + PrintUtils.print_info("聊天记录和收发的文件保存在你的主目录里, 这次没有删除; 确定不要了可以自己手动删掉") + return True + + # 腾讯官方 Linux 微信各架构的安装包, 键是 uname -m 的结果 + # (不用 dpkg --print-architecture: base.getArch 会把 32 位 armhf 也报成 arm64) + official_debs = { + "x86_64": "WeChatLinux_x86_64.deb", + "aarch64": "WeChatLinux_arm64.deb", + "loongarch64": "WeChatLinux_LoongArch.deb", + } + + @staticmethod + def installed(pkg): + status = CmdTask("dpkg-query -W -f='${{Status}}' {}".format(pkg)).run() + return "install ok installed" in "".join(status[1]) + + def remove_desktop(self): + """ + 清理已下线的桌面版 2.1.1(weixin). 它装上时把原来的 /etc/lsb-release 改名成 lsb-release-test、换上麒麟的, + 卸载脚本再挪回去; 原文件不在时卸载脚本报错, 包卡在卸了一半的状态. 所以先保证有 lsb-release-test 再卸, + 卸完要是还是麒麟的(原文件早就丢了), 按 /etc/os-release 重新生成 + """ + if CmdTask("dpkg-query -W weixin").run()[0] != 0: + return False + PrintUtils.print_info("检测到已下线的桌面版微信 v2.1.1, 一并清理并恢复被它改成麒麟的系统版本信息") + # 直接 purge 会把卸载脚本跑两遍(remove、purge 各一次), 第二遍时 lsb-release-test 已被挪走而报错, + # 所以分两步, 每步之前都补上 lsb-release-test + for action in ("remove", "purge"): + if not os.path.exists("/etc/lsb-release-test"): + CmdTask("sudo cp /etc/lsb-release /etc/lsb-release-test").run() + CmdTask("sudo apt {} -y weixin".format(action),os_command=True).run() + with open("/etc/lsb-release") as f: + if "Kylin" not in f.read(): + return True + info = {} + with open("/etc/os-release") as f: + for line in f: + if "=" in line: + key, value = line.strip().split("=", 1) + info[key] = value.strip('"') + with open("/tmp/fishros_lsb_release", "w") as f: + f.write('DISTRIB_ID={}\nDISTRIB_RELEASE={}\nDISTRIB_CODENAME={}\nDISTRIB_DESCRIPTION="{}"\n'.format( + info.get("NAME", "Ubuntu").split()[0], info.get("VERSION_ID", ""), + info.get("VERSION_CODENAME", ""), info.get("PRETTY_NAME", ""))) + CmdTask("sudo cp /tmp/fishros_lsb_release /etc/lsb-release").run() + PrintUtils.print_success("已恢复 /etc/lsb-release: {}".format(info.get("PRETTY_NAME", ""))) + return True + + def install_official(self): + """ + 官方 Linux 版微信. 社区帖 4843: 装过旧版 wechat-beta 时, 新包要覆盖它的 wechat.desktop, dpkg 报错没装上, + 原来不看结果照样提示安装完成. 现在 dpkg 报文件冲突时说明是哪个旧包、问要不要卸载后重装, 最后确认装上了才提示成功 + """ + machine = platform.machine() + deb = self.official_debs.get(machine) + if not deb: + PrintUtils.print_error("官方 Linux 版微信只提供 x86_64、arm64、龙芯(LoongArch) 安装包, 当前系统架构 {} 不支持".format(machine)) + return False + CmdTask('wget -T 15 -t 3 https://dldir1v6.qq.com/weixin/Universal/Linux/{} -O /tmp/wechat.deb'.format(deb),os_command=True).run() + if CmdTask('dpkg-deb -I /tmp/wechat.deb').run()[0] != 0: + PrintUtils.print_error("微信安装包下载不完整, 请检查网络后重试") + return False + + result = CmdTask('sudo dpkg -i /tmp/wechat.deb').run() + if result[0] != 0: + # 中文: 它同时被包含于软件包 wechat-beta 1.0.0.145; 英文: which is also in package wechat-beta 1.0.0.145 + owners = re.findall(r"(?:包含于软件包|also in package)\s+(\S+)", "\n".join(result[1] + result[2])) + old = [p for p in dict.fromkeys(owners) if p != "wechat"] + if old: + PrintUtils.print_warn("系统里已有旧版微信 {}, 和官方新版的文件冲突, 需要先卸载旧版才能安装".format(" ".join(old))) + code,_ = ChooseTask({1:"卸载旧版 {} 后继续安装".format(" ".join(old))},"是否卸载旧版:",False).run() + if code != 1: + return BaseTool.BACK + CmdTask('sudo apt remove -y {}'.format(" ".join(old)),os_command=True).run() + CmdTask('sudo dpkg -i /tmp/wechat.deb',os_command=True).run() + # 缺依赖时 dpkg 会留下未配置完的包, 让 apt 补装依赖并配置好 + CmdTask('sudo apt --fix-broken install -y',os_command=True).run() + + if self.installed("wechat"): + PrintUtils.print_success("已为你安装完成官方 Linux 版微信~ 可在应用列表里找到「微信」") + return True + PrintUtils.print_error("官方 Linux 版微信没有装上, 请把上面的报错发到鱼香社区反馈: https://fishros.org.cn/forum") + return False def run(self): - self.install_wechat() + return self.install_wechat() # sudo docker run -d --name wechat1 --device /dev/snd --ipc="host" -v /tmp/.X11-unix:/tmp/.X11-unix -v $HOME/WeChatFiles:/WeChatFiles -e DISPLAY=unix$DISPLAY -e XMODIFIERS=@im=fcitx -e QT_IM_MODULE=fcitx -e GTK_IM_MODULE=fcitx -e AUDIO_GID=`getent group audio | cut -d: -f3` bestwu/wechat \ No newline at end of file From 858f75434286c13e6c24a70f51c65152c661f58e Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:08:38 -0700 Subject: [PATCH 31/45] =?UTF-8?q?perf:=20=E5=8A=A0=E5=BF=AB=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E4=B8=BB=E8=8F=9C=E5=8D=95=E7=9A=84=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CmdTask 去掉每条命令固定的 sleep(0.5), 子进程创建好就开始轮询 - 语言文件合并成一条命令下载, 查 IP 所在国家改为后台并行 - 论坛 1733 计数改后台进程, 不再等待返回 - 引导脚本已能导入 distro/yaml 时跳过 apt install - base.py/translator.py 静默下载, 失败才提示 - 打字机效果按时间点补齐, 总时长减半(PrintUtils.delay_scale) Co-Authored-By: Claude Opus 5.5 --- install | 5 ++++- install.py | 29 ++++++++++++++----------- tools/base.py | 27 +++++++++++++++-------- tools/translation/translator.py | 38 ++++++++++++++++----------------- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/install b/install index d8ea820..d51a3b9 100644 --- a/install +++ b/install @@ -14,7 +14,10 @@ wget -q -T 15 -t 3 --no-check-certificate $FISHROS_URL/install.py -O /tmp/fishin source /etc/profile if [ -s /tmp/fishinstall/install.py ]; then - $SUDO apt install python3-distro python3-yaml -y + # 已经能导入就不再跑 apt: 每次都要读软件包列表(约 1s), 系统自动更新占着 apt 锁时还会卡住 + if ! python3 -c "import distro, yaml" 2> /dev/null; then + $SUDO apt install python3-distro python3-yaml -y + fi # sudo 默认会清掉环境变量, 显式把 FISHROS_URL 传进去, 否则后续工具一律从默认地址下载(自建服务器测试时会拿到旧版) $SUDO env FISHROS_URL="$FISHROS_URL" python3 /tmp/fishinstall/install.py else diff --git a/install.py b/install.py index e760e84..0f7f3a6 100644 --- a/install.py +++ b/install.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os import importlib +import subprocess url_prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") base_url = os.path.join(url_prefix, "tools/base.py") @@ -187,16 +188,14 @@ def main(): os.system("mkdir -p /tmp/fishinstall/tools/translation/assets") url_prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") if url_prefix: - os.system( - "wget {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( - base_url, base_url.replace(url_prefix, "") - ) - ) - os.system( - "wget {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( - translator_url, translator_url.replace(url_prefix, "") - ) - ) + # 静默下载, 只在失败时提示, 免得进菜单前刷一屏 wget 进度 + for url in (base_url, translator_url): + if os.system( + "wget -q {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( + url, url.replace(url_prefix, "") + ) + ): + print("下载失败, 请检查网络后重试: {}".format(url)) from tools.base import ( CmdTask, @@ -221,10 +220,14 @@ def main(): tracing = copy.copy(Tracking) # 使用量统计: 官网按天计启动次数(后台线程, 失败忽略), 论坛 1733 帖子的访问照旧保留 + # 论坛这次访问只为计数, 不用等它返回, 交给后台进程(一键安装退出后它会自己结束) StatUtils.report("run") - CmdTask( - "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 -t 2 && rm -rf /tmp/t1733" - ).run() + subprocess.Popen( + "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 -t 2 && rm -rf /tmp/t1733", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) PrintUtils.print_success(tr.tr("已为您切换语言至当前所在国家语言:") + tr.lang) # 只有使用官方镜像站时才切到 GitHub; 自建服务器(FISHROS_URL 指向别处)测试时保持原地址 diff --git a/tools/base.py b/tools/base.py index c21f38b..c54d7e5 100644 --- a/tools/base.py +++ b/tools/base.py @@ -888,13 +888,22 @@ def put_cmd_result(code, out, err, command): class PrintUtils: + # 打字机效果的速度倍率: 0.5 表示用原来一半的时间打完 + delay_scale = 0.5 + @staticmethod def print_delay(data, delay=0.03, end="\n"): + # 按「第 i 个字该在什么时刻出现」来补齐时间, 而不是每个字都 sleep: + # 每字 1ms 这种短停顿, 实际会被系统调度拉长到 1.3ms 以上, 菜单越长误差越大 + delay = delay * PrintUtils.delay_scale PrintUtils.print_text("\033[37m", end="") - for d in data: + start = time.perf_counter() + for i, d in enumerate(data): d = d.encode("utf-8").decode("utf-8") PrintUtils.print_text("{}".format(d), end="", flush=True) - time.sleep(delay) + wait = start + (i + 1) * delay - time.perf_counter() + if wait > 0.002: + time.sleep(wait) PrintUtils.print_text(end=end) @staticmethod @@ -1089,6 +1098,7 @@ def command_thread(self, executable="/bin/sh"): universal_newlines=True, ) self.bar = Progress() + self.sub_started.set() err = [] out = [] @@ -1117,18 +1127,17 @@ def log_callback(log, log_type): self.ret_ok = True def run_command(self, executable="/bin/sh"): + self.sub_started = threading.Event() self.command_thread = threading.Thread(target=self.command_thread) self.command_thread.start() - time.sleep(0.5) # 等待线程启动 + # 等子进程创建好就开始轮询. 原来固定 sleep(0.5), 哪怕 mkdir 这种命令也要白等半秒, 光启动阶段就累计 3~4 秒 + while not self.sub_started.wait(0.01) and self.command_thread.is_alive(): + pass while self.is_command_finish() == -1: self.bar.update_time() - time.sleep(0.1) + time.sleep(0.05) - start_time = time.time() - while ( - not self.ret_ok and time.time() - start_time < 2.0 - ): # 2s timeout wait command_thread end - time.sleep(0.1) + self.command_thread.join(2.0) # 2s timeout wait command_thread end Tracking.put_cmd_result(self.ret_code, self.ret_out, self.ret_err, self.command) return (self.ret_code, self.ret_out, self.ret_err) diff --git a/tools/translation/translator.py b/tools/translation/translator.py index 3300489..b8dd912 100644 --- a/tools/translation/translator.py +++ b/tools/translation/translator.py @@ -9,6 +9,7 @@ import tools.base from tools.base import CmdTask import subprocess +import threading import time _suported_languages = ['zh_CN', 'en_US'] @@ -27,30 +28,29 @@ class Linguist: def __init__(self): # Initialize the current locale. self.country = 'CN' - self._currentLocale = self.getLocalFromIP() + # 查 IP 所在国家要走一趟外网(约 1s), 放到后台和下载语言文件同时进行 + ip_result = [] + ip_thread = threading.Thread(target=lambda: ip_result.append(self.getLocalFromIP())) + ip_thread.daemon = True + ip_thread.start() + if url_prefix: + # 两个语言文件用一条命令下完: 原来 mkdir/wget/mv 拆成 6 条 CmdTask, 刷屏且每条都有额外等待 + # 先下到 /tmp/ 再挪过去, 避免目标目录权限问题; 下载失败就删掉半截文件 + download_cmds = ["mkdir -p /tmp/fishinstall/tools/translation/assets"] + for lang in _suported_languages: + temp_file = "/tmp/fishros_lang_{}.py".format(lang) + final_path = "/tmp/fishinstall/{}".format(lang_url.format(lang).replace(url_prefix, '')) + download_cmds.append("(wget -q {} -O {} -T 15 -t 3 --no-check-certificate && mv {} {} || rm -f {})".format( + lang_url.format(lang), temp_file, temp_file, final_path, temp_file)) + CmdTask("; ".join(download_cmds)).run() + ip_thread.join(16) + self._currentLocale = ip_result[0] if ip_result else None if self._currentLocale is None: # 查不到 IP 所在国家(断网、DNS 故障、接口不可用)时按系统语言来, 而不是一律英文 self._currentLocale = self.getLocalFromSystem() # Load the translation file. self.lang = self._currentLocale - # Create directory for downloads - CmdTask("mkdir -p /tmp/fishinstall/tools/translation/assets").run() - if url_prefix: - for lang in _suported_languages: - # Add timeout and retry mechanism for downloading language files - # Use /tmp/ directory directly to avoid permission issues - temp_file = "/tmp/fishros_lang_{}.py".format(lang) - final_path = "/tmp/fishinstall/{}".format(lang_url.format(lang).replace(url_prefix, '')) - download_cmd = "wget {} -O {} -T 15 -t 3 --no-check-certificate".format(lang_url.format(lang), temp_file) - result = CmdTask(download_cmd).run() - # Move file to final destination if download was successful - if result[0] == 0: - CmdTask("mkdir -p $(dirname {})".format(final_path)).run() - CmdTask("mv {} {}".format(temp_file, final_path)).run() - else: - # Clean up temp file if download failed - CmdTask("rm -f {}".format(temp_file)).run() - + self.loadTranslationFile() tools.base.tr = self From 77485cb65a1050207c691b69cad97dc0dd1a9857 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:13:53 -0700 Subject: [PATCH 32/45] =?UTF-8?q?fix:=20=E6=89=93=E5=AD=97=E6=9C=BA?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8E=9F=E9=80=9F;=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E6=98=BE=E7=A4=BA=E5=BD=93=E5=89=8D=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?;=20/tmp=20=E4=B8=8B=E8=BF=90=E8=A1=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E6=94=BE=E4=B8=8A=E6=AC=A1=E9=80=89?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打字机效果恢复原来的实现和速度, 用户要先对比效果 - 基础检查后打印「当前系统: 名称 版本 (代号) 架构」, 原来 dpkg 查到的架构会被进度行盖掉 - 运行结束写的 /tmp/fish_install.yaml 只在 FISH_INSTALL_CONFIG 明确指定时才读, 避免在 /tmp 目录下运行时按上次的选项自动执行; 读到配置文件时提示一行 Co-Authored-By: Claude Opus 5.5 --- install.py | 6 ++++++ tools/base.py | 21 ++++++++++----------- tools/translation/assets/en_US.py | 1 + 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/install.py b/install.py index 0f7f3a6..fba129a 100644 --- a/install.py +++ b/install.py @@ -249,6 +249,12 @@ def main(): print("Solutions: https://fishros.org.cn/forum/topic/24 ") return False PrintUtils.print_success(tr.tr("基础检查通过...")) + # 把系统和架构打出来: 用户发截图求助时一眼能看到是什么系统(dpkg 查到的架构原来会被进度行盖掉) + try: + PrintUtils.print_info(tr.tr("当前系统:") + " {} {} ({}) {}".format( + osversion.get_name(), osversion.get_version(), osversion.get_codename(), osarch)) + except Exception: + pass book = tr.tr(""" .-~~~~~~~~~-._ _.-~~~~~~~~~-. diff --git a/tools/base.py b/tools/base.py index c54d7e5..b6f382e 100644 --- a/tools/base.py +++ b/tools/base.py @@ -43,7 +43,15 @@ def __init__(self, record_file=None): self.record_file = os.environ.get( "FISH_INSTALL_CONFIG", "./fish_install.yaml" ) + # 每次运行结束都会把本次选择记到 /tmp/fish_install.yaml, 在 /tmp 下运行时默认路径正好指向这份记录, + # 就会不声不响地按上次的选项自动执行. 这份记录只在用 FISH_INSTALL_CONFIG 明确指定时才读 + if "FISH_INSTALL_CONFIG" not in os.environ and os.path.realpath( + self.record_file + ) == os.path.realpath("/tmp/fish_install.yaml"): + self.record_file = "" self.default_input_queue = self.get_default_queue(self.record_file) + if self.default_input_queue.qsize() > 0: + print("检测到配置文件 {}, 将按其中的选项自动选择".format(self.record_file)) def record_input(self, item): self.record_input_queue.put(item) @@ -888,22 +896,13 @@ def put_cmd_result(code, out, err, command): class PrintUtils: - # 打字机效果的速度倍率: 0.5 表示用原来一半的时间打完 - delay_scale = 0.5 - @staticmethod def print_delay(data, delay=0.03, end="\n"): - # 按「第 i 个字该在什么时刻出现」来补齐时间, 而不是每个字都 sleep: - # 每字 1ms 这种短停顿, 实际会被系统调度拉长到 1.3ms 以上, 菜单越长误差越大 - delay = delay * PrintUtils.delay_scale PrintUtils.print_text("\033[37m", end="") - start = time.perf_counter() - for i, d in enumerate(data): + for d in data: d = d.encode("utf-8").decode("utf-8") PrintUtils.print_text("{}".format(d), end="", flush=True) - wait = start + (i + 1) * delay - time.perf_counter() - if wait > 0.002: - time.sleep(wait) + time.sleep(delay) PrintUtils.print_text(end=end) @staticmethod diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 0fd8d2b..8a069c7 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -1,5 +1,6 @@ translations = { "基础检查通过...": "Base check passed...", + "当前系统:": "Current system:", """===============================================================================\n======欢迎使用一键安装工具,人生苦短,三省吾身,省时省力省心!======= ======一键安装已开源,请放心使用:https://github.com/fishros/install =======\n===============================================================================\n """: """===============================================================================\n====== Welcome to the One-Click Installation Tool, life is short, save your time and energy! =======\n====== One-click installation has been open sourced, please feel free to use: https://github.com/fishros/install =======\n===============================================================================\n""", R""" From a28c0ef462589c27d35de27179e03be22db859aa Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:06:21 -0700 Subject: [PATCH 33/45] =?UTF-8?q?fix(docker):=20=E5=85=AC=E9=92=A5?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=A5=E5=AE=89=E8=A3=85=E6=88=90=E5=8A=9F,=20=E5=B9=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E6=AE=8B=E7=95=99=E7=9A=84?= =?UTF-8?q?=E5=9D=8F=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公钥依次从清华/中科大/阿里云镜像下载(带重试和超时), 校验是 PGP 公钥再用, 源和公钥用同一个镜像 - 上次失败留下的无公钥 docker.list 会让 apt update 一直报错、重跑直接退出, 安装前先清理 - 软件源更新失败时撤掉刚加的源, 不影响之后的 apt 操作 - 装完用 docker --version 确认再报成功, 失败返回 False; 不支持的架构直接提示 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_docker.py | 77 ++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/tools/tool_install_docker.py b/tools/tool_install_docker.py index f7d4304..516e4fe 100644 --- a/tools/tool_install_docker.py +++ b/tools/tool_install_docker.py @@ -1,55 +1,106 @@ # -*- coding: utf-8 -*- +import os from .base import BaseTool from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask from .base import osversion, osarch from .base import run_tool_file +# 按顺序尝试, 哪个镜像的公钥能下下来就用哪个镜像的软件源(公钥和源要来自同一处) +docker_mirrors = [ + "https://mirrors.tuna.tsinghua.edu.cn/docker-ce", + "https://mirrors.ustc.edu.cn/docker-ce", + "https://mirrors.aliyun.com/docker-ce", +] +docker_key = "/etc/apt/keyrings/docker.asc" +docker_list = "/etc/apt/sources.list.d/docker.list" + + class Tool(BaseTool): def __init__(self): self.name = "一键安装Docker,支持amd64和arm64架构系统" self.type = BaseTool.TYPE_INSTALL self.author = 'alyssa' + @staticmethod + def key_ok(): + try: + with open(docker_key, "r") as f: + return "BEGIN PGP PUBLIC KEY BLOCK" in f.read() + except Exception: + return False + + def remove_broken_source(self): + """ + 上次公钥没下载成功时会留下一个没有公钥的 docker.list, 之后每次 apt update 都报 NO_PUBKEY, + 下面的 checkapt 也会因此失败直接退出, 用户自己很难看出原因, 所以先清掉 + """ + if os.path.exists(docker_list) and not self.key_ok(): + PrintUtils.print_warn("检测到上次没装好留下的Docker软件源(缺少公钥), 先清理掉再重新安装~") + CmdTask("sudo rm -f {}".format(docker_list)).run() + + def download_key(self): + """依次从各镜像下载公钥, 返回下载成功的镜像地址, 都失败返回 None""" + for mirror in docker_mirrors: + CmdTask('curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 "{}/linux/ubuntu/gpg" -o {}'.format(mirror, docker_key)).run() + if self.key_ok(): + CmdTask('chmod a+r {}'.format(docker_key)).run() + return mirror + PrintUtils.print_warn("从{}下载Docker公钥失败, 换下一个镜像试试~".format(mirror)) + CmdTask("sudo rm -f {}".format(docker_key)).run() + return None + def install_docker(self): """ Executing Docker install script with updated commands for new installation steps. """ PrintUtils.print_info("开始根据系统架构,为你下载对应版本的docker~") + if osarch not in ('amd64', 'arm64'): + PrintUtils.print_error("暂不支持当前系统架构:{}, 目前只支持amd64和arm64".format(osarch)) + return False + self.remove_broken_source() # Check if apt is available if not AptUtils.checkapt(): return False # Pre-installation steps - CmdTask('apt-get update', 120, os_command=True).run() CmdTask('DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl ', 120, os_command=True).run() CmdTask('install -m 0755 -d /etc/apt/keyrings', 10, os_command=True).run() # Add Docker GPG key - CmdTask('curl -fsSL "https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu/gpg" -o /etc/apt/keyrings/docker.asc', 10, os_command=True).run() - CmdTask('chmod a+r /etc/apt/keyrings/docker.asc', 10,os_command=True).run() + mirror = self.download_key() + if mirror is None: + PrintUtils.print_error("Docker公钥下载失败, 请检查网络后重试") + return False # Add Docker repository - if osarch == 'amd64': - CmdTask('echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list', os_command=True).run() - elif osarch == 'arm64': - CmdTask('echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list', os_command=True).run() - else: - return False + CmdTask('echo "deb [arch={} signed-by={}] {}/linux/ubuntu $(lsb_release -cs) stable" > {}'.format( + osarch, docker_key, mirror, docker_list), os_command=True).run() # Update apt index and install Docker PrintUtils.print_info("下载完成,接下来升级apt索引~") - CmdTask('apt-get update ', 10,os_command=True).run() + if CmdTask('apt-get update', 120).run()[0] != 0: + # 源加上了却更新不了时撤掉, 免得影响之后所有 apt 操作 + PrintUtils.print_error("Docker软件源更新失败, 已撤销添加的软件源, 请检查网络后重试") + CmdTask("sudo rm -f {} {}".format(docker_list, docker_key)).run() + return False PrintUtils.print_info("开始安装最新版本docker CE~") CmdTask('DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin', 120,os_command=True).run() + result = CmdTask('docker --version').run() + if result[0] != 0: + PrintUtils.print_error("Docker安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + # Post-installation steps - CmdTask('sudo groupadd docker', 10,os_command=True).run() + CmdTask('sudo groupadd -f docker', 10,os_command=True).run() users = FileUtils.getusers() for user in users: CmdTask('sudo gpasswd -a {} docker'.format(user), 10,os_command=True).run() - PrintUtils.print_info("安装完成,接下来你可以尝试使用docker --version指令测试是有正常回显~") + PrintUtils.print_success("Docker安装成功: {}".format(" ".join(result[1]))) + PrintUtils.print_info("当前用户需要重新登录(或重启)后才能不加sudo使用docker命令~") + return True def run(self): - self.install_docker() + return self.install_docker() From 745e5dbcff6f37d2e412adea759847d22eaa2908 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:11:00 -0700 Subject: [PATCH 34/45] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=87?= =?UTF-8?q?=E6=97=B6/=E5=A4=B1=E6=95=88=E7=9A=84=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=80=89=E9=A1=B9,=20=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=A5=E5=AE=89=E8=A3=85=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VSCode: 改为下载微软最新稳定版, apt 装本地包, 装完确认版本 - Node.js: 可选 18/20(已停止维护)/22/24(推荐)/26, 默认改 24; 下载加超时 - OpenCode: 去掉已停用的 registry.npm.taobao.org, 改用 npmmirror - Docker 代理: 只保留可用的华为云加速, 回车直接用; 写 daemon.json 保留原有配置 - ROS Docker: 补 kilted, 修正各版本维护状态; 国内未配加速时自动配华为云; 拉镜像最多 5 次并确认镜像存在, Docker 或镜像失败时明确报错 - Cartographer: 22.04 及以后提示只支持 ROS1, ROS2 用菜单 22 - 系统自带 ROS: 源里没有 ros-base-dev(24.04 起)直接提示; 按实际结果提示 - QQ: 官网下载链接是带签名的临时链接, 改为安装用户在 ~/下载 或 ~/Downloads 里下好的 deb, 跳过没下完/架构不符的包, 找不到就提示去官网下载 - base.py 新增 DockerUtils(加速地址与 daemon.json 读写) Co-Authored-By: Claude Opus 5.5 --- tools/base.py | 37 ++++++++++++++ tools/tool_config_docker_proxy.py | 22 ++++----- tools/tool_install_cartographer.py | 8 ++-- tools/tool_install_nodejs.py | 38 +++++++-------- tools/tool_install_opencode.py | 9 ++-- tools/tool_install_qq.py | 61 +++++++++++++++++------- tools/tool_install_ros1_systemdefault.py | 16 ++++++- tools/tool_install_ros_with_docker.py | 45 ++++++++++++----- tools/tool_install_vscode.py | 34 ++++++++----- 9 files changed, 188 insertions(+), 82 deletions(-) diff --git a/tools/base.py b/tools/base.py index b6f382e..d27636f 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1486,6 +1486,43 @@ def check_result(result, patterns: list): return False +class DockerUtils: + # 国内直连 Docker Hub 不通, 拉镜像要靠加速地址. 2026-10 实测公开的加速地址里只有这个华为云的还稳定可用 + # (dockerpull.com / docker.fxxk.dedyn.io 已失效, DaoCloud 只放行部分镜像, 对 fishros2/ros 返回 403) + registry_mirror = "https://ac77d18f43e44819b553040f294b8f9b.mirror.swr.myhuaweicloud.com" + daemon_json = "/etc/docker/daemon.json" + + @staticmethod + def set_registry_mirror(url): + """写入 daemon.json 的 registry-mirrors 并重启 Docker, 保留文件里原有的其它配置""" + import json + + config = {} + try: + with open(DockerUtils.daemon_json, "r") as f: + config = json.load(f) + except Exception: + config = {} + if not isinstance(config, dict): + config = {} + config["registry-mirrors"] = [url] + CmdTask("sudo mkdir -p /etc/docker").run() + with open(DockerUtils.daemon_json, "w") as f: + json.dump(config, f, indent=2) + CmdTask("sudo systemctl daemon-reload").run() + return CmdTask("sudo systemctl restart docker").run()[0] == 0 + + @staticmethod + def has_registry_mirror(): + import json + + try: + with open(DockerUtils.daemon_json, "r") as f: + return len(json.load(f).get("registry-mirrors", [])) > 0 + except Exception: + return False + + class AptUtils: @staticmethod def checkapt(): diff --git a/tools/tool_config_docker_proxy.py b/tools/tool_config_docker_proxy.py index e619516..d0497f8 100644 --- a/tools/tool_config_docker_proxy.py +++ b/tools/tool_config_docker_proxy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file @@ -18,7 +18,8 @@ def config_docker_proxy(self): dic = {1:"VPN模式(需要本地有支持HTTP代理)",2:"服务模式(需要提供支持Docker代理的服务器地址)",3:"删除所有模式代理"} code,result = ChooseTask(dic, "请选择代理模式,如果不清楚可以选2").run() - # 根据系统架构下载不同版本的安装包 + if code==0: + return BaseTool.BACK if code==1: url = input('请输入代理所在地址(直接回车使用:127.0.0.1):') if not url: @@ -33,17 +34,12 @@ def config_docker_proxy(self): CmdTask("sudo systemctl daemon-reload").run() CmdTask("sudo systemctl restart docker").run() elif code==2: - url = input('请输入代理网址(直接回车将进入选择界面):') + # 原来的 dockerpull.com / docker.fxxk.dedyn.io 已失效, 只保留华为云加速, 回车直接用它 + url = input('请输入代理网址(直接回车使用华为云加速):').strip() if not url: - dic = {1:"https://dockerpull.com",2:"https://docker.fxxk.dedyn.io",3:"https://ac77d18f43e44819b553040f294b8f9b.mirror.swr.myhuaweicloud.com"} - code,result = ChooseTask(dic, "请选择代理加速网址").run() - url = dic[code] - print(url) - proxy_config_vpn = '{{"registry-mirrors": ["{}"]}}'.format(url) - FileUtils.new('/etc/docker/','daemon.json',proxy_config_vpn) - PrintUtils.print_info("在{}/{}写入了{},接下来为你重启Docker!".format('/etc/docker/', 'daemon.json', proxy_config_vpn)) - CmdTask("sudo systemctl daemon-reload").run() - CmdTask("sudo systemctl restart docker").run() + url = DockerUtils.registry_mirror + PrintUtils.print_info("在{}写入加速地址{}, 接下来为你重启Docker!".format(DockerUtils.daemon_json, url)) + DockerUtils.set_registry_mirror(url) elif code==3: CmdTask("sudo rm /etc/systemd/system/docker.service.d/proxy.conf").run() CmdTask("sudo rm /etc/docker/daemon.json").run() @@ -52,5 +48,5 @@ def config_docker_proxy(self): PrintUtils.print_info("配置完成,接下来你可以尝试再次拉取镜像了~") def run(self): - self.config_docker_proxy() + return self.config_docker_proxy() diff --git a/tools/tool_install_cartographer.py b/tools/tool_install_cartographer.py index 3913d16..ff3e1cb 100644 --- a/tools/tool_install_cartographer.py +++ b/tools/tool_install_cartographer.py @@ -28,8 +28,10 @@ def install_docker(self): if ros_version!=None: PrintUtils.print_info("检测到您的系统版ROS版本为:{}".format(ros_version)) else: - PrintUtils.print_info("检测系统版本失败,请反馈以下信息到鱼香社区:Fail on check ros version with {}".format(osversion.get_version())) - return + # 不是 bug: 这里是编译 ROS1 版 Cartographer, 22.04 及以后没有 ROS1. 原来提示「检测失败请反馈」会让人以为脚本坏了 + PrintUtils.print_warn("一键编译Cartographer只支持ROS1(Ubuntu 16.04/18.04/20.04), 当前系统是Ubuntu {}".format(osversion.get_version())) + PrintUtils.print_info("ROS2用户请使用菜单[22]一键安装ROS常用功能包, 里面已包含Cartographer") + return False # check apt if not AptUtils.checkapt(): return False @@ -52,5 +54,5 @@ def install_docker(self): CmdTask("sudo chmod -R 777 cartographer_ws").run() def run(self): - self.install_docker() + return self.install_docker() diff --git a/tools/tool_install_nodejs.py b/tools/tool_install_nodejs.py index 019873b..1ceb788 100644 --- a/tools/tool_install_nodejs.py +++ b/tools/tool_install_nodejs.py @@ -56,28 +56,28 @@ def _cleanup_legacy(self): ) def _choose_target_major(self): + # 18 已于 2025-04、20 已于 2026-04 停止维护, 只为老项目保留 version_dic = { - 1: "Node.js 22 LTS(默认推荐)", - 2: "Node.js 20 LTS", - 3: "Node.js 18 LTS(兼容)", - 4: "自定义主版本号", + 1: "Node.js 18(已停止维护,仅兼容老项目)", + 2: "Node.js 20(已停止维护,仅兼容老项目)", + 3: "Node.js 22 LTS", + 4: "Node.js 24 LTS(推荐)", + 5: "Node.js 26 LTS(最新)", + 6: "自定义主版本号", } - code, _ = ChooseTask(version_dic, "请选择Node.js默认版本:", False).run() - if code == 1: - return 22 - if code == 2: - return 20 - if code == 3: - return 18 - if code == 4: - custom = input("请输入Node.js主版本号(如22):").strip() + version_map = {1: 18, 2: 20, 3: 22, 4: 24, 5: 26} + code, _ = ChooseTask(version_dic, "请选择Node.js默认版本(不知道选哪个就选4):", False).run() + if code in version_map: + return version_map[code] + if code == 6: + custom = input("请输入Node.js主版本号(如24):").strip() if custom.isdigit() and int(custom) > 0: return int(custom) - PrintUtils.print_warn("输入无效,默认使用22") - return 22 + PrintUtils.print_warn("输入无效,默认使用24") + return 24 if os.environ.get("FISH_INSTALL_CONFIG") is not None: - PrintUtils.print_warn("自动化模式未提供Node.js版本,默认使用22") - return 22 + PrintUtils.print_warn("自动化模式未提供Node.js版本,默认使用24") + return 24 PrintUtils.print_warn("已取消Node.js安装") return None @@ -379,7 +379,7 @@ def _install_from_ustc_tar(self, target_major): downloaded = False for tar_url, cur_tar_name in tar_urls: download_result = self._run_cmd( - "wget --no-proxy {} -O /tmp/nodejs.tar.xz".format(tar_url), + "wget --no-proxy -T 15 -t 3 {} -O /tmp/nodejs.tar.xz".format(tar_url), 300, "下载Node.js安装包...", ) @@ -424,7 +424,7 @@ def _install_from_ustc_tar(self, target_major): self._run_cmd("rm -rf /tmp/nodejs.tar.xz", 10) return True - def install_nodejs_version(self, target_major=22, with_registry=True): + def install_nodejs_version(self, target_major=24, with_registry=True): self._cleanup_legacy() current_major = self._detect_node_major() if current_major is not None and current_major >= target_major: diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index ed89979..f176764 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -8,7 +8,8 @@ class Tool(BaseTool): NPM_MIRROR = "https://registry.npmmirror.com" - NPM_TAOBAO = "https://registry.npm.taobao.org" + # 淘宝源早已迁到 npmmirror, 旧域名 registry.npm.taobao.org 2024 年起停用, 连不上只会白白多试一次 + NPM_TAOBAO = "https://registry.npmmirror.com" PREFERRED_NRM_ALIASES = ["taobao", "tencent", "huawei", "npm", "yarn", "cnpm"] def __init__(self): @@ -236,7 +237,7 @@ def _switch_npm_registry(self): sources = node_tool._get_nrm_sources() taobao_url = self.NPM_TAOBAO - if "taobao" in sources and str(sources["taobao"]).strip() != taobao_url: + if "taobao" in sources and str(sources["taobao"]).strip().rstrip("/") != taobao_url: PrintUtils.print_warn( "检测到nrm中的taobao源为{},将优先使用固定taobao地址{}".format( sources["taobao"], taobao_url @@ -330,8 +331,8 @@ def _ensure_nodejs(self): and self._code(check_sudo_npm) == 0 ): return True - PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 22") - return NodejsTool().install_nodejs_version(22, with_registry=False) + PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 24") + return NodejsTool().install_nodejs_version(24, with_registry=False) def install_opencode(self): if not self._ensure_nodejs(): diff --git a/tools/tool_install_qq.py b/tools/tool_install_qq.py index 659a544..f260a3f 100644 --- a/tools/tool_install_qq.py +++ b/tools/tool_install_qq.py @@ -1,34 +1,61 @@ # -*- coding: utf-8 -*- +import glob +import os from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask from .base import osversion, osarch from .base import run_tool_file +# QQ 官网的下载地址是带 sign 和时间戳的临时链接, 只有从官网页面点下载时才会生成, 过期就 403, +# 脚本里写死的地址(原来的 linuxqq_3.2.1)早已失效. 所以改成: 用户在官网下载 deb, 脚本负责找到并安装 +qq_download_page = "https://im.qq.com/linuxqq/index.shtml" +qq_deb_patterns = ["QQ_*.deb", "linuxqq_*.deb"] +download_dirs = ["下载", "Downloads"] + + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_INSTALL self.name = "一键安装QQ" self.author = '五柳小生' + def find_qq_deb(self): + """在各用户的 ~/下载 和 ~/Downloads 里找 QQ 安装包, 返回最新且架构匹配的一个""" + debs = [] + for home in FileUtils.getusershome(): + for folder in download_dirs: + for pattern in qq_deb_patterns: + debs += glob.glob(os.path.join(home, folder, pattern)) + debs.sort(key=os.path.getmtime, reverse=True) + for deb in debs: + result = CmdTask('dpkg-deb -f "{}" Architecture'.format(deb)).run() + if result[0] != 0 or len(result[1]) == 0: + PrintUtils.print_warn("{} 不是完整的安装包(可能没下载完), 跳过".format(deb)) + continue + arch = result[1][0].strip() + # 龙芯的包写 loongarch64, dpkg 报的是 loong64 + if arch.replace("loongarch64", "loong64") != osarch: + PrintUtils.print_warn("{} 是{}版本, 和当前系统({})不符, 跳过".format(deb, arch, osarch)) + continue + return deb + return None + def install_qq(self): - PrintUtils.print_info("开始根据系统架构,为你下载对应版本的QQ~") - if osarch == 'arm64': - CmdTask('sudo wget https://dldir1.qq.com/qqfile/qq/QQNT/b69de82d/linuxqq_3.2.1-17153_arm64.deb -O /tmp/qq.deb',os_command=True).run() - elif osarch == 'amd64': - CmdTask('sudo wget https://dldir1.qq.com/qqfile/qq/QQNT/b69de82d/linuxqq_3.2.1-17153_amd64.deb -O /tmp/qq.deb',os_command=True).run() - else: + deb = self.find_qq_deb() + if deb is None: + PrintUtils.print_warn("没有在「下载」目录里找到QQ安装包") + PrintUtils.print_info("请先打开 {} 下载 deb 版(当前系统架构: {}), 下完再运行一次".format(qq_download_page, osarch)) return False - PrintUtils.print_info("下载完成,接下来为您安装QQ") - # 安装依赖 - CmdTask("sudo apt-get update") - CmdTask("sudo apt install libgtk2.0-0") - # 解压安装包 - CmdTask("sudo dpkg -i /tmp/qq.deb").run() - CmdTask("rm -rf /tmp/qq.deb").run() - PrintUtils.print_info("安装完成~") - - + pkg = CmdTask('dpkg-deb -f "{}" Package'.format(deb)).run()[1][0].strip() + PrintUtils.print_info("找到QQ安装包: {}, 开始安装~".format(deb)) + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt install -y "{}"'.format(deb), os_command=True).run() + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' " + pkg).run()[1]): + PrintUtils.print_error("QQ安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("QQ安装成功~") + return True def run(self): #正式的运行 - self.install_qq() \ No newline at end of file + return self.install_qq() diff --git a/tools/tool_install_ros1_systemdefault.py b/tools/tool_install_ros1_systemdefault.py index f6a4b90..1fb3e88 100644 --- a/tools/tool_install_ros1_systemdefault.py +++ b/tools/tool_install_ros1_systemdefault.py @@ -12,12 +12,18 @@ def __init__(self): PrintUtils.print_warn("警告!!此工具安装的ROS与从ROS官方软件源安装的ROS冲突!") def run(self): + # Ubuntu 24.04 起软件源里已经没有系统自带 ROS(ros-base-dev 等), 先查一下, 免得换完源才发现装不了 + if CmdTask("apt-cache show ros-base-dev").run()[0] != 0: + PrintUtils.print_error("当前系统(Ubuntu {})的软件源里没有系统自带ROS(Ubuntu 24.04起已移除)".format(osversion.get_version())) + PrintUtils.print_info("请使用菜单[1]一键安装ROS") + return False #正式的运行: 每一步选 [0] 都退回前一步 while True: if self.check_sys_source() == BaseTool.BACK: return BaseTool.BACK - if self.install_system_ros() != BaseTool.BACK: - return True + result = self.install_system_ros() + if result != BaseTool.BACK: + return result def check_sys_source(self): # 更换系统源: 先检测当前系统源是国内还是国外再给建议, 选 [0] 返回 BaseTool.BACK @@ -73,4 +79,10 @@ def install_system_ros(self): if code==2: cmd_result = CmdTask("sudo {} install {} -y".format(install_tool,"ros-base-dev"),300,os_command=False).run() elif code==1: cmd_result = CmdTask("sudo {} install {} -y".format(install_tool,"ros-desktop-full-dev"),300,os_command=False).run() + # 按实际安装结果提示, 不再一律当成功 + pkg = "ros-base-dev" if code==2 else "ros-desktop-full-dev" + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' " + pkg).run()[1]): + PrintUtils.print_error("系统自带ROS安装失败: 如果之前装过ROS官方源的ROS2, 两者会冲突, 请使用菜单[1]一键安装ROS") + return False + PrintUtils.print_success("系统自带ROS安装成功~") return True diff --git a/tools/tool_install_ros_with_docker.py b/tools/tool_install_ros_with_docker.py index d29d9e3..87a83ca 100644 --- a/tools/tool_install_ros_with_docker.py +++ b/tools/tool_install_ros_with_docker.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file +from .base import tr class RosVersion: STATUS_EOL = 0 STATUS_LTS = 1 + STATUS_ACTIVE = 2 # 还在维护但不是长期支持版(普通版 / rolling) def __init__(self,name,version,status,images=[],arm_images=[]): self.name = name self.version = version @@ -19,20 +21,21 @@ class RosVersions: ros_version = [ RosVersion('lyrical', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:lyrical-desktop'],["osrf/ros:lyrical-desktop"]), RosVersion('jazzy', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:jazzy-desktop-full'],["ros:jazzy"]), - RosVersion('noetic', 'ROS1', RosVersion.STATUS_LTS, ['fishros2/ros:noetic-desktop-full'],["ros:noetic"]), + RosVersion('noetic', 'ROS1', RosVersion.STATUS_EOL, ['fishros2/ros:noetic-desktop-full'],["ros:noetic"]), RosVersion('humble', 'ROS2', RosVersion.STATUS_LTS, ['fishros2/ros:humble-desktop-full'],["ros:humble"]), + RosVersion('kilted', 'ROS2', RosVersion.STATUS_ACTIVE, ['osrf/ros:kilted-desktop-full'],["ros:kilted"]), RosVersion('foxy', 'ROS2', RosVersion.STATUS_EOL, ['fishros2/ros:foxy-desktop'],["ros:foxy"]), - RosVersion('galactic', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:galactic-desktop'],["ros:galactic"]), - RosVersion('iron', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:iron-desktop-full'],["ros:iron"]), - RosVersion('melodic', 'ROS1', RosVersion.STATUS_LTS, ['fishros2/ros:melodic-desktop-full'],["ros:melodic"]), - RosVersion('rolling', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:rolling-desktop-full'],["ros:rolling"]), + RosVersion('galactic', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:galactic-desktop'],["ros:galactic"]), + RosVersion('iron', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:iron-desktop-full'],["ros:iron"]), + RosVersion('melodic', 'ROS1', RosVersion.STATUS_EOL, ['fishros2/ros:melodic-desktop-full'],["ros:melodic"]), + RosVersion('rolling', 'ROS2', RosVersion.STATUS_ACTIVE, ['osrf/ros:rolling-desktop-full'],["ros:rolling"]), RosVersion('kinetic', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:kinetic-desktop-full'],["ros:kinetic"]), RosVersion('eloquent', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:eloquent-desktop'],["ros:eloquent"]), RosVersion('dashing', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:dashing-desktop'],["ros:dashing"]), RosVersion('crystal', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:crystal-desktop'],["ros:crystal"]), RosVersion('bouncy', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:bouncy-desktop'],["ros:bouncy"]), RosVersion('ardent', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:ardent-desktop'],["ros:ardent"]), - RosVersion('lunar', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:lunar-desktop'],["ros:lunar"]), + RosVersion('lunar', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:lunar-desktop'],["ros:lunar"]), RosVersion('indigo', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:indigo-desktop-full'],["ros:indigo"]) ] @@ -47,6 +50,8 @@ def get_version_string(name): if version.name == ros_version: if version.status==RosVersion.STATUS_EOL: eol = "停止维护" + elif version.status==RosVersion.STATUS_ACTIVE: + eol = "维护中(非长期支持版)" else: eol = "长期支持" return "{}({}),该版本目前状态:{}".format(version.name,version.version,eol), ros_version @@ -149,9 +154,21 @@ def install_docker(self): def download_image(self,name): """""" PrintUtils.print_success("=================3.下载镜像(该步骤因网络原因会慢一些,若失败请重试)==================") - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() + # 国内直连 Docker Hub 不通, 没配加速地址时自动配上, 不用再先去跑一遍 [17] + if tr.isCN() and not DockerUtils.has_registry_mirror(): + PrintUtils.print_info("检测到Docker还没有配置镜像加速, 自动为你配置华为云加速: {}".format(DockerUtils.registry_mirror)) + DockerUtils.set_registry_mirror(DockerUtils.registry_mirror) + image = RosVersions.get_image(name) + PrintUtils.print_info("镜像{}一般有几个GB, 请确认磁盘空间足够, 耐心等待~".format(image)) + # 华为云加速偶尔会断开连接(EOF), 一层断了整次拉取就失败, 但已下完的层会保留, 重拉会接着下, 最多 5 次 + for i in range(5): + if i > 0: + PrintUtils.print_warn("镜像下载中断(加速服务器偶尔会断开), 第{}次重试, 已下载的部分不会重新下~".format(i)) + CmdTask('sudo docker pull {} '.format(image),os_command=True).run() + if CmdTask('sudo docker image inspect {}'.format(image)).run()[0] == 0: + return True + PrintUtils.print_error("镜像{}下载失败, 请检查网络后重试".format(image)) + return False # create image # TODO 更换好系统源 @@ -253,7 +270,11 @@ def install_ros_with_docker(self): if not rosname: return False self.install_docker() - self.download_image(rosname) + if CmdTask("docker --version").run()[0] != 0: + PrintUtils.print_error("Docker没有装好, 无法继续安装ROS Docker版") + return False + if not self.download_image(rosname): + return False container_name = self.create_container(rosname) self.generte_command(container_name,rosname) self.install_use_tool() @@ -266,4 +287,4 @@ def install_ros_with_docker(self): PrintUtils.print_info("请访问社区的一键安装版块:https://fishros.org.cn/forum/topic/112") def run(self): - self.install_ros_with_docker() + return self.install_ros_with_docker() diff --git a/tools/tool_install_vscode.py b/tools/tool_install_vscode.py index 8b51669..0324a9a 100644 --- a/tools/tool_install_vscode.py +++ b/tools/tool_install_vscode.py @@ -4,6 +4,11 @@ from .base import osversion,osarch from .base import run_tool_file +# 微软官方的「最新稳定版」地址, 会 302 跳到国内也能访问的 CDN(vscode.download.prss.microsoft.com) +# 原来写死 1.86.2(2024-02), 改成每次装最新版 +vscode_latest_url = "https://update.code.visualstudio.com/latest/linux-deb-{}/stable" +vscode_arch = {'amd64': 'x64', 'arm64': 'arm64', 'armhf': 'armhf'} + class Tool(BaseTool): def __init__(self): self.name = "一键安装Vscode" @@ -11,22 +16,27 @@ def __init__(self): self.author = '小鱼' def install_vscode(self): - """ - https://az764295.vo.msecnd.net - """ PrintUtils.print_info("开始根据系统架构,为你下载对应版本的vscode~") # 根据系统架构下载不同版本的安装包 - if osarch=='amd64': - CmdTask('sudo wget https://vscode.download.prss.microsoft.com/dbazure/download/stable/903b1e9d8990623e3d7da1df3d33db3e42d80eda/code_1.86.2-1707854558_amd64.deb -O /tmp/vscode.deb',os_command=True).run() - elif osarch=='arm64': - CmdTask('sudo wget https://vscode.download.prss.microsoft.com/dbazure/download/stable/903b1e9d8990623e3d7da1df3d33db3e42d80eda/code_1.86.2-1707853305_arm64.deb -O /tmp/vscode.deb',os_command=True).run() - else: + if osarch not in vscode_arch: + PrintUtils.print_error("暂不支持当前系统架构:{}".format(osarch)) + return False + CmdTask('sudo wget -T 15 -t 3 {} -O /tmp/vscode.deb'.format(vscode_latest_url.format(vscode_arch[osarch])),os_command=True).run() + if CmdTask("dpkg-deb -I /tmp/vscode.deb").run()[0] != 0: + PrintUtils.print_error("VSCode安装包下载失败, 请检查网络后重试") + CmdTask("rm -rf /tmp/vscode.deb").run() return False PrintUtils.print_info("下载完成,接下来为你安装Vscode~") - CmdTask("sudo dpkg -i /tmp/vscode.deb").run() + # 用 apt 装本地包, 缺依赖时会一起装上(dpkg -i 缺依赖就直接失败) + CmdTask("sudo DEBIAN_FRONTEND=noninteractive apt install -y /tmp/vscode.deb", os_command=True).run() CmdTask("rm -rf /tmp/vscode.deb").run() - PrintUtils.print_info("安装完成~") + result = CmdTask("dpkg-query -W -f='${Status} ${Version}' code").run() + status = " ".join(result[1]) + if result[0] != 0 or "install ok installed" not in status: + PrintUtils.print_error("VSCode安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("VSCode安装成功, 版本: {}".format(status.replace("install ok installed", "").strip())) + return True def run(self): - self.install_vscode() - + return self.install_vscode() From 857b3556f3331f8e12da6bfe1856f8b551589a77 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:02:17 -0700 Subject: [PATCH 35/45] =?UTF-8?q?fix:=20GitHub=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=89=88=E6=94=B9=E7=94=A8=20shiftkey=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E9=95=9C=E5=83=8F;=20Docker=20=E6=94=AF=E6=8C=81=20Debian/?= =?UTF-8?q?=E6=A0=91=E8=8E=93=E6=B4=BE/Mint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub 桌面版: github.fishros.org 中转已失效, 改为读 mirror.mwt.me(shiftkey 官方 apt 镜像) 的软件包索引下载最新版, 不加成 apt 源; 装完确认; 镜像只有 amd64, 其它架构提示手动下载 - Docker: 从 /etc/os-release 判断软件源路径和代号(UBUNTU_CODENAME 走 ubuntu, raspbian/debian 各走各的), 架构用 dpkg 的真实值(32 位树莓派是 armhf); Debian 12、Mint 22 容器实测通过 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_docker.py | 42 ++++++++++++++--- tools/tool_install_github_desktop.py | 67 +++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/tools/tool_install_docker.py b/tools/tool_install_docker.py index 516e4fe..2b17f33 100644 --- a/tools/tool_install_docker.py +++ b/tools/tool_install_docker.py @@ -21,6 +21,30 @@ def __init__(self): self.type = BaseTool.TYPE_INSTALL self.author = 'alyssa' + @staticmethod + def docker_repo(): + """ + 返回 (源路径, 系统代号). 原来写死 linux/ubuntu + lsb_release -cs, Debian 路径不对, + Linux Mint 的 lsb_release 给的是 Mint 自己的代号(如 wilma), Docker 源里都没有 + """ + info = {} + try: + with open("/etc/os-release", "r") as f: + for line in f: + if "=" in line: + key, value = line.strip().split("=", 1) + info[key] = value.strip('"') + except Exception: + pass + # Ubuntu 及基于 Ubuntu 的发行版(Mint、Pop!_OS 等)都带 UBUNTU_CODENAME + if info.get("UBUNTU_CODENAME"): + return "ubuntu", info["UBUNTU_CODENAME"] + if info.get("ID") == "raspbian": + return "raspbian", info.get("VERSION_CODENAME") + if info.get("ID") == "debian" or "debian" in info.get("ID_LIKE", "").split(): + return "debian", info.get("VERSION_CODENAME") + return "ubuntu", info.get("VERSION_CODENAME") + @staticmethod def key_ok(): try: @@ -38,10 +62,10 @@ def remove_broken_source(self): PrintUtils.print_warn("检测到上次没装好留下的Docker软件源(缺少公钥), 先清理掉再重新安装~") CmdTask("sudo rm -f {}".format(docker_list)).run() - def download_key(self): + def download_key(self, distro): """依次从各镜像下载公钥, 返回下载成功的镜像地址, 都失败返回 None""" for mirror in docker_mirrors: - CmdTask('curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 "{}/linux/ubuntu/gpg" -o {}'.format(mirror, docker_key)).run() + CmdTask('curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 "{}/linux/{}/gpg" -o {}'.format(mirror, distro, docker_key)).run() if self.key_ok(): CmdTask('chmod a+r {}'.format(docker_key)).run() return mirror @@ -54,9 +78,13 @@ def install_docker(self): Executing Docker install script with updated commands for new installation steps. """ PrintUtils.print_info("开始根据系统架构,为你下载对应版本的docker~") - if osarch not in ('amd64', 'arm64'): - PrintUtils.print_error("暂不支持当前系统架构:{}, 目前只支持amd64和arm64".format(osarch)) + # osarch 会把 armhf 当成 arm64, 写软件源要用 dpkg 报的真实架构(32 位树莓派系统是 armhf) + arch = CmdTask("dpkg --print-architecture").run()[1][0].strip() + if arch not in ('amd64', 'arm64', 'armhf'): + PrintUtils.print_error("暂不支持当前系统架构:{}, 目前只支持amd64、arm64和armhf".format(arch)) return False + distro, codename = self.docker_repo() + PrintUtils.print_info("将使用Docker的{}软件源, 系统代号:{}".format(distro, codename)) self.remove_broken_source() # Check if apt is available @@ -68,14 +96,14 @@ def install_docker(self): CmdTask('install -m 0755 -d /etc/apt/keyrings', 10, os_command=True).run() # Add Docker GPG key - mirror = self.download_key() + mirror = self.download_key(distro) if mirror is None: PrintUtils.print_error("Docker公钥下载失败, 请检查网络后重试") return False # Add Docker repository - CmdTask('echo "deb [arch={} signed-by={}] {}/linux/ubuntu $(lsb_release -cs) stable" > {}'.format( - osarch, docker_key, mirror, docker_list), os_command=True).run() + CmdTask('echo "deb [arch={} signed-by={}] {}/linux/{} {} stable" > {}'.format( + arch, docker_key, mirror, distro, codename, docker_list), os_command=True).run() # Update apt index and install Docker PrintUtils.print_info("下载完成,接下来升级apt索引~") diff --git a/tools/tool_install_github_desktop.py b/tools/tool_install_github_desktop.py index 130a398..c5867e9 100644 --- a/tools/tool_install_github_desktop.py +++ b/tools/tool_install_github_desktop.py @@ -1,25 +1,72 @@ # -*- coding: utf-8 -*- +import re from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask -from .base import osversion +from .base import osversion,osarch from .base import run_tool_file +# 原来走 github.fishros.org 中转下载写死的 2.9.12, 中转已不返回数据(wget 会卡几十分钟). +# 改用 shiftkey 项目 README 里给出的官方 apt 镜像, 国内实测约 1.7MB/s; 从它的软件包索引里取最新版下载, +# 不把它加成 apt 源: 镜像万一挂了, apt update 会一直报错, 连累其它工具 +mwt_mirror = "https://mirror.mwt.me/shiftkey-desktop/deb" +github_releases = "https://github.com/shiftkey/desktop/releases" + + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_INSTALL self.name = "安装Github桌面版" self.author = '小鱼' + @staticmethod + def version_key(version): + return [int(n) for n in re.findall(r"\d+", version)] + + def latest_deb(self): + """读镜像的软件包索引, 返回最新版的 (版本号, 下载地址), 失败返回 None""" + result = CmdTask("wget -qO- -T 15 -t 3 {}/dists/any/main/binary-{}/Packages".format(mwt_mirror, osarch)).run() + if result[0] != 0: + return None + packages, current = [], {} + for line in result[1] + [""]: + if not line.strip(): + if "Version" in current and "Filename" in current: + packages.append(current) + current = {} + elif ":" in line: + key, value = line.split(":", 1) + current[key.strip()] = value.strip() + if len(packages) == 0: + return None + latest = max(packages, key=lambda p: self.version_key(p["Version"])) + return latest["Version"], "{}/{}".format(mwt_mirror, latest["Filename"]) + def install_github(self): - """ - wget http://github.fishros.org/https://github.com/shiftkey/desktop/releases/download/release-2.9.12-linux4/GitHubDesktop-linux-2.9.12-linux4.deb -O /tmp/github.deb - sudo dpkg -i /tmp/github.deb - sudo apt install /tmp/github.deb -y - """ - CmdTask('sudo wget http://github.fishros.org/https://github.com/shiftkey/desktop/releases/download/release-2.9.12-linux4/GitHubDesktop-linux-2.9.12-linux4.deb -O /tmp/github.deb',os_command=True).run() - CmdTask('sudo dpkg -i /tmp/github.deb').run() - CmdTask('sudo apt install /tmp/github.deb -y').run() + if osarch != "amd64": + # 镜像目前只有 amd64 版 + PrintUtils.print_error("国内镜像只提供amd64版本, {}版本请到 {} 手动下载安装".format(osarch, github_releases)) + return False + PrintUtils.print_info("正在获取GitHub桌面版最新版本~") + latest = self.latest_deb() + if latest is None: + PrintUtils.print_error("获取GitHub桌面版版本信息失败, 请检查网络后重试") + return False + version, url = latest + PrintUtils.print_info("开始下载GitHub桌面版 {}(约120MB)~".format(version)) + CmdTask('sudo wget -T 15 -t 3 {} -O /tmp/github.deb'.format(url), os_command=True).run() + if CmdTask("dpkg-deb -I /tmp/github.deb").run()[0] != 0: + PrintUtils.print_error("GitHub桌面版安装包下载失败, 请检查网络后重试") + CmdTask("rm -rf /tmp/github.deb").run() + return False + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt install -y /tmp/github.deb', os_command=True).run() + CmdTask("rm -rf /tmp/github.deb").run() + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' github-desktop").run()[1]): + PrintUtils.print_error("GitHub桌面版安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("GitHub桌面版 {} 安装成功, 可以在应用列表里找到 GitHub Desktop~".format(version)) + return True def run(self): #正式的运行 - self.install_github() + return self.install_github() From 8757d4ae261528a2f4bbec7e3b68cb1cab106114 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:05:37 -0700 Subject: [PATCH 36/45] =?UTF-8?q?fix:=20=E5=AE=B9=E5=99=A8=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=91=BD=E4=BB=A4=E5=9C=A8=E6=B2=A1=E6=9C=89=20Docker?= =?UTF-8?q?=20=E6=9D=83=E9=99=90=E6=97=B6=E7=BB=99=E5=87=BA=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 刚装完 Docker 时加入 docker 组要重新登录才生效, 当前终端执行 wechat 等控制命令会报 permission denied. 在微信和 ROS Docker 生成的控制脚本开头检查 docker.sock 是否可写, 不可写时提示注销重新登录(或先执行 newgrp docker)并退出, 不改动权限本身 Co-Authored-By: Claude Opus 5.5 --- tools/base.py | 8 ++++++++ tools/tool_install_ros_with_docker.py | 4 ++-- tools/tool_install_wechat.py | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tools/base.py b/tools/base.py index d27636f..e033eac 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1491,6 +1491,14 @@ class DockerUtils: # (dockerpull.com / docker.fxxk.dedyn.io 已失效, DaoCloud 只放行部分镜像, 对 fishros2/ros 返回 403) registry_mirror = "https://ac77d18f43e44819b553040f294b8f9b.mirror.swr.myhuaweicloud.com" daemon_json = "/etc/docker/daemon.json" + # 放在生成的容器控制脚本(wechat、ROS 容器名等命令)开头: 刚装完 Docker 时加入 docker 组要重新登录才生效, + # 当前终端不加 sudo 执行 docker 会报 permission denied, 用户看不懂, 这里先给出提示 + permission_check_sh = """if [ -S /var/run/docker.sock ] && [ ! -w /var/run/docker.sock ]; then + echo "当前终端还没有使用Docker的权限: 刚装完Docker时需要注销重新登录(或重启)才会生效" + echo "也可以先执行 newgrp docker, 再运行本命令" + exit 1 +fi +""" @staticmethod def set_registry_mirror(url): diff --git a/tools/tool_install_ros_with_docker.py b/tools/tool_install_ros_with_docker.py index 87a83ca..9c88e71 100644 --- a/tools/tool_install_ros_with_docker.py +++ b/tools/tool_install_ros_with_docker.py @@ -129,8 +129,8 @@ def get_container_scripts(self, name, rosversion, delete_file): newgrp docker """.format(name,name,name,name,name,name,name,delete_command,name) if rosversion=="ROS1": - return ros1 - return ros2 + return DockerUtils.permission_check_sh + ros1 + return DockerUtils.permission_check_sh + ros2 def choose_image_version(self): """获取要安装的ROS版本""" diff --git a/tools/tool_install_wechat.py b/tools/tool_install_wechat.py index 8121bbb..c264f15 100644 --- a/tools/tool_install_wechat.py +++ b/tools/tool_install_wechat.py @@ -4,7 +4,7 @@ import re import shutil from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file @@ -15,7 +15,7 @@ def __init__(self): self.author = '小鱼' def get_wechat_scripts(self,name,build): - return """xhost +local: >> /dev/null + return DockerUtils.permission_check_sh + """xhost +local: >> /dev/null echo "请输入指令控制{}: 启动(s) 配置:(w) 关闭(c) 重启(r) 删除(d) 重建(b):" read choose case $choose in From 7acf9758b3d668ff73f5288b8e5c3bbafddc6bc5 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:22:06 -0800 Subject: [PATCH 37/45] =?UTF-8?q?feat(proxy):=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=20FlClash,=20=E6=8C=89=E5=8A=A0=E9=80=9F?= =?UTF-8?q?=E7=AB=99=E4=BE=9D=E6=AC=A1=E5=B0=9D=E8=AF=95=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?,=20=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 菜单改为 FlClash 0.9.2(推荐)/Clash Verge Rev 2.5.8, 去掉 mihomo-party - 下载按 ghfast.top -> gh-proxy.com -> repo.trojan-cdn.com 依次尝试, 每个先试 15 秒, 平均速度达到 200KB/s 就用它下完, 都达不到时用测到最快的那个下完; 下完校验 deb - 下载时显示进度条、实时速度和剩余时间, 输出重定向时降低刷新频率 - proxy_add 按客户端默认端口写入(Clash Verge Rev 是 7897, 原来写死 7890 用不了) - 选 0 返回上一步, run 返回安装结果 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_proxy_tool.py | 256 ++++++++++++++++++++----------- 1 file changed, 168 insertions(+), 88 deletions(-) diff --git a/tools/tool_install_proxy_tool.py b/tools/tool_install_proxy_tool.py index 304397f..3f52818 100644 --- a/tools/tool_install_proxy_tool.py +++ b/tools/tool_install_proxy_tool.py @@ -1,17 +1,55 @@ # -*- coding: utf-8 -*- +import collections import os +import shutil +import sys +import time +import urllib.request from urllib.parse import quote from .base import BaseTool from .base import PrintUtils, CmdTask, FileUtils, ChooseTask from .base import osarch +# 可选的客户端. port 是各客户端默认的混合代理端口, 写进 proxy_add 快捷命令 +# (Clash Verge Rev 默认是 7897 不是 7890, 原来一律写 7890, 装它的用户 proxy_add 用不了) +CLIENTS = { + 1: { + "name": "FlClash", + "tip": "FlClash(推荐): 有界面, 安装包最小(约40MB)", + "repo": "chen08209/FlClash", + "tag": "v0.9.2", + "cdn_dir": "FlClash/v0.9.2", + "file": "FlClash-0.9.2-linux-{arch}.deb", + "port": 7890, + "cmds": ["FlClash", "flclash"], + }, + 2: { + "name": "Clash Verge Rev", + "tip": "Clash Verge Rev: 有界面(约100MB)", + "repo": "clash-verge-rev/clash-verge-rev", + "tag": "v2.5.8", + "cdn_dir": "clash-verge-rev/Clash%20Verge%20Rev%20v2.5.8", + "file": "Clash.Verge_2.5.8_{arch}.deb", + "port": 7897, + "cmds": ["clash-verge", "clash-verge-rev"], + }, +} + +# 国内直连 GitHub 基本下不动, 按顺序尝试: 两个公共 GitHub 加速站(快但时好时坏, 有时会拒绝某些仓库), +# 最后用 repo.trojan-cdn.com 兜底(一直能下, 但国内只有十几 KB/s). 2026-11 在国内服务器实测 +DOWNLOAD_SOURCES = [ + ("ghfast.top 加速", "https://ghfast.top/https://github.com/{repo}/releases/download/{tag}/{file}"), + ("gh-proxy.com 加速", "https://gh-proxy.com/https://github.com/{repo}/releases/download/{tag}/{file}"), + ("trojan-cdn 镜像", "https://repo.trojan-cdn.com/{cdn_dir}/{file}"), +] +# 每个源先下 15 秒, 平均速度达到 200KB/s 就用它下完; 都达不到时, 用测下来最快的那个源下完 +# (不能简单地「慢了就换下一个」: 实测 gh-proxy.com 89KB/s 被放弃, 最后落到只有 15KB/s 的 trojan-cdn, 反而更慢) +SLOW_CHECK_SECONDS = 15 +SLOW_SPEED = 200 * 1024 + class Tool(BaseTool): - CLASH_VERGE_BASE = ( - "https://repo.trojan-cdn.com/clash-verge-rev/Clash%20Verge%20Rev%20v2.4.6" - ) - MIHOMO_PARTY_BASE = "https://repo.trojan-cdn.com/mihomo-party/v1.9.2" RECOMMEND_SUBSCRIBE_URL = "https://fishros.org.cn/forum/topic/668" def __init__(self): @@ -41,51 +79,101 @@ def _out(self, result): return [] def _choose_install_mode(self): - mode_dic = { - 1: "有界面版: Clash Verge Rev", - 2: "无界面版(按提供源): mihomo-party", - } - code, _ = ChooseTask(mode_dic, "请选择代理工具版本:", False).run() + mode_dic = {code: client["tip"] for code, client in CLIENTS.items()} + code, _ = ChooseTask(mode_dic, "请选择代理工具(不知道选哪个就选1):", False).run() return code - def _resolve_package(self, mode): - if osarch not in ["amd64", "arm64"]: - return None, None, None - - if mode == 1: - filename_map = { - "amd64": "Clash.Verge_2.4.6_amd64.deb", - "arm64": "Clash.Verge_2.4.6_arm64.deb", - } - file_name = filename_map[osarch] - return ( - "Clash Verge Rev", - "{}/{}".format(self.CLASH_VERGE_BASE, file_name), - file_name, - ) - - if mode == 2: - filename_map = { - "amd64": "mihomo-party-linux-1.9.2-amd64.deb", - "arm64": "mihomo-party-linux-1.9.2-arm64.deb", - } - file_name = filename_map[osarch] - return ( - "mihomo-party", - "{}/{}".format(self.MIHOMO_PARTY_BASE, file_name), - file_name, - ) - - return None, None, None + @staticmethod + def _format_speed(speed): + if speed >= 1048576: + return "{:.1f}MB/s".format(speed / 1048576.0) + return "{:.0f}KB/s".format(speed / 1024.0) + + @staticmethod + def _draw_progress(done, total, speed): + """在同一行画进度条: [████████░░░░░░░░] 45% 17.5/38.7MB 2.4MB/s 剩余 9s""" + if total: + left = (total - done) / speed if speed > 0 else -1 + eta = "--" if left < 0 else ("{}m{:02d}s".format(int(left) // 60, int(left) % 60) if left >= 60 else "{}s".format(int(left))) + info = " {:3.0f}% {:.1f}/{:.1f}MB {} 剩余 {}".format( + done * 100.0 / total, done / 1048576.0, total / 1048576.0, Tool._format_speed(speed), eta) + # 进度条宽度随终端宽度变化; info 里的「剩余」两个汉字各占 2 列, 多留几列余量免得折行 + width = max(10, min(40, shutil.get_terminal_size((80, 20)).columns - len(info) - 10)) + filled = int(width * done / total) + line = " [{}{}]{}".format("█" * filled, "░" * (width - filled), info) + else: + line = " 已下载 {:.1f}MB {}".format(done / 1048576.0, Tool._format_speed(speed)) + sys.stdout.write("\r" + line + " ") + sys.stdout.flush() + + @staticmethod + def _download_from(url, path, give_up_if_slow): + """ + 下载到 path, 返回 (是否下完, 平均速度 B/s). give_up_if_slow 时, 下了 SLOW_CHECK_SECONDS 秒平均速度还太慢就放弃. + 不走环境变量里的代理(和原来 wget --no-proxy 一致), 超时 15 秒 + """ + speed = 0 + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21"}) + # 在终端里每 0.2 秒刷新一次; 输出被重定向到文件(日志、自动化测试)时 5 秒一次, 免得刷屏 + interval = 0.2 if sys.stdout.isatty() else 5 + try: + with opener.open(request, timeout=15) as resp, open(path, "wb") as f: + total = int(resp.headers.get("Content-Length") or 0) + done, start, last_print = 0, time.time(), 0 + recent = collections.deque([(start, 0)]) # 最近约 2 秒的 (时间, 已下载), 算实时速度 + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + f.write(chunk) + done += len(chunk) + now = time.time() + used = now - start + speed = done / max(used, 0.001) # 平均速度, 用来判断这个源快不快 + recent.append((now, done)) + while len(recent) > 2 and now - recent[0][0] > 2: + recent.popleft() + if now - last_print >= interval: + last_print = now + span = now - recent[0][0] + Tool._draw_progress(done, total, (done - recent[0][1]) / span if span > 0 else speed) + if give_up_if_slow and used > SLOW_CHECK_SECONDS and speed < SLOW_SPEED: + print("") + PrintUtils.print_warn("速度较慢({}), 先试试下一个下载源".format(Tool._format_speed(speed))) + return False, speed + Tool._draw_progress(done, total, speed) + print("") + return (total == 0 or done == total), speed + except Exception as e: + print("") + PrintUtils.print_warn("下载失败: {}".format(e)) + return False, 0 + + def _download_package(self, client, path): + """按 DOWNLOAD_SOURCES 的顺序尝试, 下到完整的 deb 就返回 True""" + params = dict(client, file=client["file"].format(arch=osarch)) + slow_sources = [] + for source_name, template in DOWNLOAD_SOURCES: + url = template.format(**params) + PrintUtils.print_info("从 {} 下载: {}".format(source_name, url)) + ok, speed = self._download_from(url, path, give_up_if_slow=True) + if ok and self._code(CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()) == 0: + return True + if speed > 0: + slow_sources.append((speed, source_name, url)) + # 没有一个源够快: 用测下来最快的那个下完 + for speed, source_name, url in sorted(slow_sources, reverse=True): + PrintUtils.print_warn("几个下载源都比较慢, 改用其中最快的 {}({}) 下完, 请耐心等待".format(source_name, self._format_speed(speed))) + ok, _ = self._download_from(url, path, give_up_if_slow=False) + if ok and self._code(CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()) == 0: + return True + return False - def _install_deb_package(self, package_url): + def _install_deb_package(self, client): temp_file = "/tmp/fishros_proxy_tool.deb" - download = self._run_cmd( - 'wget --no-proxy "{}" -O "{}"'.format(package_url, temp_file), - 300, - "下载代理工具安装包中...", - ) - if self._code(download) != 0: + if not self._download_package(client, temp_file): + self._run_cmd('rm -f "{}"'.format(temp_file), 10) return False install = self._run_cmd( @@ -147,32 +235,34 @@ def _auto_import_subscription(self, subscribe_url): PrintUtils.print_info(clash_link) return False - def _setup_proxy_alias(self, user_home): + def _setup_proxy_alias(self, user_home, port): bashrc = os.path.join(user_home.rstrip("/"), ".bashrc") if not os.path.exists(bashrc): FileUtils.new(user_home.rstrip("/") + "/", name=".bashrc", data="") start_flag = "# >>> fishros proxy alias >>>" end_flag = "# <<< fishros proxy alias <<<" + # 端口是所选客户端的默认端口, 在客户端里改过端口的话改这里的 FISHROS_PROXY_PORT 即可 block = ( - "{}\n" - "proxy_add(){\n" - " export http_proxy=http://127.0.0.1:7890\n" - " export https_proxy=http://127.0.0.1:7890\n" - " export all_proxy=socks5://127.0.0.1:7890\n" - " echo 'proxy on: 127.0.0.1:7890'\n" - "}\n" - "proxy_off(){\n" + "{start}\n" + "FISHROS_PROXY_PORT={port}\n" + "proxy_add(){{\n" + " export http_proxy=http://127.0.0.1:$FISHROS_PROXY_PORT\n" + " export https_proxy=http://127.0.0.1:$FISHROS_PROXY_PORT\n" + " export all_proxy=socks5://127.0.0.1:$FISHROS_PROXY_PORT\n" + " echo \"proxy on: 127.0.0.1:$FISHROS_PROXY_PORT\"\n" + "}}\n" + "proxy_off(){{\n" " unset http_proxy https_proxy all_proxy\n" " echo 'proxy off'\n" - "}\n" - "proxy_status(){\n" + "}}\n" + "proxy_status(){{\n" ' echo "http_proxy=$http_proxy"\n' ' echo "https_proxy=$https_proxy"\n' ' echo "all_proxy=$all_proxy"\n' - "}\n" - "{}" - ).format(start_flag, end_flag) + "}}\n" + "{end}" + ).format(start=start_flag, end=end_flag, port=port) with open(bashrc, "r") as f: data = f.read() @@ -189,7 +279,7 @@ def _setup_proxy_alias(self, user_home): with open(bashrc, "w") as f: f.write(data) - PrintUtils.print_success("已写入终端快捷命令: proxy_add/proxy_off/proxy_status") + PrintUtils.print_success("已写入终端快捷命令: proxy_add/proxy_off/proxy_status(端口 {})".format(port)) def _which(self, name): result = self._run_cmd("command -v {}".format(name), 10) @@ -197,26 +287,15 @@ def _which(self, name): return str(self._out(result)[0]).strip() return None - def _print_launch_tips(self, mode): + def _print_launch_tips(self, client): PrintUtils.print_delay("==========使用方法===========") - if mode == 1: - cmd_path = self._which("clash-verge") - if cmd_path is None: - cmd_path = self._which("clash-verge-rev") - if cmd_path: - PrintUtils.print_delay("1.启动有界面客户端: {}".format(cmd_path)) - else: - PrintUtils.print_delay( - "1.启动有界面客户端: 在应用菜单中搜索 Clash Verge" - ) + cmd_path = None + for cmd in client["cmds"]: + cmd_path = cmd_path or self._which(cmd) + if cmd_path: + PrintUtils.print_delay("1.启动客户端: 在应用菜单中搜索 {}, 或终端运行 {}".format(client["name"], cmd_path)) else: - cmd_path = self._which("mihomo-party") - if cmd_path is None: - cmd_path = self._which("clash-party") - if cmd_path: - PrintUtils.print_delay("1.启动工具: {}".format(cmd_path)) - else: - PrintUtils.print_delay("1.启动工具: 在应用菜单中搜索 mihomo-party") + PrintUtils.print_delay("1.启动客户端: 在应用菜单中搜索 {}".format(client["name"])) PrintUtils.print_delay("2.开启终端代理: proxy_add") PrintUtils.print_delay("3.关闭终端代理: proxy_off") @@ -225,22 +304,23 @@ def _print_launch_tips(self, mode): def install_proxy_tool(self): mode = self._choose_install_mode() - if mode not in [1, 2]: + if mode == 0: + return BaseTool.BACK + if mode not in CLIENTS: PrintUtils.print_warn("已取消安装") return False - - package_name, package_url, file_name = self._resolve_package(mode) - if package_name is None: + if osarch not in ["amd64", "arm64"]: PrintUtils.print_error("当前仅支持 amd64/arm64 架构") return False + client = CLIENTS[mode] PrintUtils.print_info("当前架构: {}".format(osarch)) - PrintUtils.print_info("准备安装: {} ({})".format(package_name, file_name)) - if not self._install_deb_package(package_url): + PrintUtils.print_info("准备安装: {} {}".format(client["name"], client["tag"])) + if not self._install_deb_package(client): PrintUtils.print_error("安装失败,请检查网络、系统软件源或依赖修复状态") return False - PrintUtils.print_success("{} 安装完成".format(package_name)) + PrintUtils.print_success("{} 安装完成".format(client["name"])) subscribe_url = self._read_input_subscription() if subscribe_url: @@ -254,12 +334,12 @@ def install_proxy_tool(self): user_homes = FileUtils.getusershome() if len(user_homes) > 0: - self._setup_proxy_alias(user_homes[0]) + self._setup_proxy_alias(user_homes[0], client["port"]) else: PrintUtils.print_warn("未找到用户目录,跳过 proxy_add 快捷命令写入") - self._print_launch_tips(mode) + self._print_launch_tips(client) return True def run(self): - self.install_proxy_tool() + return self.install_proxy_tool() From bd2ccbef45f3c9bef8eb4409f23ed538eb10ace7 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:55:50 -0800 Subject: [PATCH 38/45] =?UTF-8?q?feat(ai-tools):=20=E8=8F=9C=E5=8D=95=2018?= =?UTF-8?q?=20=E6=89=A9=E5=B1=95=E4=B8=BA=207=20=E4=B8=AA=20AI=20=E7=BC=96?= =?UTF-8?q?=E7=A8=8B=E5=B7=A5=E5=85=B7;=20=E4=B8=8B=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E6=8C=AA=E5=88=B0=20base.py=20=E5=85=B1=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm 安装: OpenCode / Codex / Claude Code / DeepSeek Harness(dsh, 预览版) / Pi, 复用原 OpenCode 的 npm 目录修复、国内源切换、重试和命令链接修复; 按各工具要求检查 Node.js 版本(Claude Code 要 22+, Pi 要 22.19+), 不够就装 Node.js 24 - deb 安装: CC Switch / Cockpit Tools, 读 GitHub releases/latest 跳转自动取最新版, 取不到时用已知版本; 装完用 dpkg-query 确认; 支持卸载 - base.py 新增 DownloadUtils: GitHub 加速站依次尝试、进度条和实时速度, [14] 代理工具改用它 - 先选工具再选安装/卸载, 两步都能选 0 返回; 文件名沿用 tool_install_opencode.py Co-Authored-By: Claude Opus 5.5 --- install.py | 2 +- tools/base.py | 143 ++++++++++++++++++ tools/tool_install_opencode.py | 248 ++++++++++++++++++++++--------- tools/tool_install_proxy_tool.py | 109 +------------- 4 files changed, 329 insertions(+), 173 deletions(-) diff --git a/install.py b/install.py index fba129a..3134261 100644 --- a/install.py +++ b/install.py @@ -135,7 +135,7 @@ "dep": [], }, 18: { - "tip": "一键安装/卸载:OpenCode(AI编程助手)", + "tip": "一键安装/卸载:AI编程工具(OpenCode/Codex/Claude Code/DeepSeek/Pi/CC Switch/Cockpit Tools)", "type": CAT_DEV_TOOLS, "tool": "tools/tool_install_opencode.py", "dep": [6], diff --git a/tools/base.py b/tools/base.py index e033eac..855d90b 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1531,6 +1531,149 @@ def has_registry_mirror(): return False +class DownloadUtils: + """ + 从多个下载源里挑能用的下大文件(GitHub Releases 的安装包等), 带进度条、实时速度和剩余时间. + 国内直连 GitHub 下载基本下不动, 一般按「公共加速站 → 兜底源」排列 + """ + + # 2026-11 国内服务器实测可用的 GitHub 加速站, 快但时好时坏, 有时会拒绝某些仓库 + github_mirrors = [ + ("ghfast.top 加速", "https://ghfast.top/https://github.com/"), + ("gh-proxy.com 加速", "https://gh-proxy.com/https://github.com/"), + ] + # 每个源先下 15 秒, 平均速度达到 200KB/s 就用它下完; 都达不到时, 用测下来最快的那个源下完 + # (不能简单地「慢了就换下一个」: 实测 gh-proxy.com 89KB/s 被放弃, 最后落到只有 15KB/s 的源, 反而更慢) + slow_check_seconds = 15 + slow_speed = 200 * 1024 + + @staticmethod + def github_sources(repo, tag, file_name): + """GitHub Releases 文件的各个下载地址: 加速站在前, GitHub 直连最后""" + path = "{}/releases/download/{}/{}".format(repo, tag, file_name) + return [(name, prefix + path) for name, prefix in DownloadUtils.github_mirrors] + \ + [("GitHub 直连", "https://github.com/" + path)] + + @staticmethod + def github_latest_tag(repo): + """ + 读 github.com//releases/latest 的跳转地址得到最新版本号(如 v4.0.24), 失败返回 None. + 这个请求很小, 国内直连也能拿到; 直连不通再走加速站 + """ + import urllib.error + import urllib.request + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener(NoRedirect, urllib.request.ProxyHandler({})) + prefixes = ["https://github.com/"] + [prefix for _, prefix in DownloadUtils.github_mirrors] + for prefix in prefixes: + try: + opener.open(urllib.request.Request(prefix + repo + "/releases/latest", method="HEAD"), timeout=10) + except urllib.error.HTTPError as e: + match = re.search(r"/releases/tag/([^/?#]+)$", e.headers.get("Location") or "") + if match: + return match.group(1) + except Exception: + pass + return None + + @staticmethod + def format_speed(speed): + if speed >= 1048576: + return "{:.1f}MB/s".format(speed / 1048576.0) + return "{:.0f}KB/s".format(speed / 1024.0) + + @staticmethod + def draw_progress(done, total, speed): + """在同一行画进度条: [████████░░░░░░░░] 45% 17.5/38.7MB 2.4MB/s 剩余 9s""" + if total: + left = (total - done) / speed if speed > 0 else -1 + eta = "--" if left < 0 else ("{}m{:02d}s".format(int(left) // 60, int(left) % 60) if left >= 60 else "{}s".format(int(left))) + info = " {:3.0f}% {:.1f}/{:.1f}MB {} 剩余 {}".format( + done * 100.0 / total, done / 1048576.0, total / 1048576.0, DownloadUtils.format_speed(speed), eta) + # 进度条宽度随终端宽度变化; info 里的「剩余」两个汉字各占 2 列, 多留几列余量免得折行 + width = max(10, min(40, shutil.get_terminal_size((80, 20)).columns - len(info) - 10)) + filled = int(width * done / total) + line = " [{}{}]{}".format("█" * filled, "░" * (width - filled), info) + else: + line = " 已下载 {:.1f}MB {}".format(done / 1048576.0, DownloadUtils.format_speed(speed)) + sys.stdout.write("\r" + line + " ") + sys.stdout.flush() + + @staticmethod + def download_from(url, path, give_up_if_slow): + """ + 下载到 path, 返回 (是否下完, 平均速度 B/s). give_up_if_slow 时, 下了 slow_check_seconds 秒平均速度还太慢就放弃. + 不走环境变量里的代理(和 wget --no-proxy 一致), 超时 15 秒 + """ + import collections + import urllib.request + + speed = 0 + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21"}) + # 在终端里每 0.2 秒刷新一次; 输出被重定向到文件(日志、自动化测试)时 5 秒一次, 免得刷屏 + interval = 0.2 if sys.stdout.isatty() else 5 + try: + with opener.open(request, timeout=15) as resp, open(path, "wb") as f: + total = int(resp.headers.get("Content-Length") or 0) + done, start, last_print = 0, time.time(), 0 + recent = collections.deque([(start, 0)]) # 最近约 2 秒的 (时间, 已下载), 算实时速度 + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + f.write(chunk) + done += len(chunk) + now = time.time() + used = now - start + speed = done / max(used, 0.001) # 平均速度, 用来判断这个源快不快 + recent.append((now, done)) + while len(recent) > 2 and now - recent[0][0] > 2: + recent.popleft() + if now - last_print >= interval: + last_print = now + span = now - recent[0][0] + DownloadUtils.draw_progress(done, total, (done - recent[0][1]) / span if span > 0 else speed) + if give_up_if_slow and used > DownloadUtils.slow_check_seconds and speed < DownloadUtils.slow_speed: + print("") + PrintUtils.print_warn("速度较慢({}), 先试试下一个下载源".format(DownloadUtils.format_speed(speed))) + return False, speed + DownloadUtils.draw_progress(done, total, speed) + print("") + return (total == 0 or done == total), speed + except Exception as e: + print("") + PrintUtils.print_warn("下载失败: {}".format(e)) + return False, 0 + + @staticmethod + def download_deb(sources, path): + """sources 是 [(名称, 地址), ...], 按顺序尝试, 下到完整的 deb 就返回 True""" + def deb_ok(): + return CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()[0] == 0 + + slow_sources = [] + for source_name, url in sources: + PrintUtils.print_info("从 {} 下载: {}".format(source_name, url)) + ok, speed = DownloadUtils.download_from(url, path, give_up_if_slow=True) + if ok and deb_ok(): + return True + if speed > 0: + slow_sources.append((speed, source_name, url)) + # 没有一个源够快: 用测下来最快的那个下完 + for speed, source_name, url in sorted(slow_sources, reverse=True): + PrintUtils.print_warn("几个下载源都比较慢, 改用其中最快的 {}({}) 下完, 请耐心等待".format( + source_name, DownloadUtils.format_speed(speed))) + ok, _ = DownloadUtils.download_from(url, path, give_up_if_slow=False) + if ok and deb_ok(): + return True + return False + + class AptUtils: @staticmethod def checkapt(): diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index f176764..6203482 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -1,10 +1,45 @@ # -*- coding: utf-8 -*- +# 菜单 [18]: 一键安装/卸载 AI 编程工具. 原来只装 OpenCode, 文件名沿用(菜单的工具路径是对外接口, 不改名) import os +import re from .base import BaseTool -from .base import PrintUtils, CmdTask, ChooseTask +from .base import PrintUtils, CmdTask, ChooseTask, DownloadUtils +from .base import osarch from .tool_install_nodejs import Tool as NodejsTool +# npm 安装的命令行工具. node 是需要的最低 Node.js 版本(取自各包 package.json 的 engines, 没写的按 20 算) +NPM_APPS = { + 1: {"name": "OpenCode", "tip": "OpenCode: 开源AI编程助手(命令行/网页)", + "pkg": "opencode-ai", "bin": "opencode", "node": (20, 0), + "usage": ["启动: opencode", "网页版: opencode web"]}, + 2: {"name": "Codex", "tip": "Codex: OpenAI 的命令行编程助手", + "pkg": "@openai/codex", "bin": "codex", "node": (16, 0), + "usage": ["启动: codex (首次使用按提示登录 OpenAI 账号或配置 API Key)"]}, + 3: {"name": "Claude Code", "tip": "Claude Code: Anthropic 的命令行编程助手", + "pkg": "@anthropic-ai/claude-code", "bin": "claude", "node": (22, 0), + "usage": ["启动: claude (需要 Anthropic 账号, 或用 CC Switch 切换到其它兼容的模型供应商)"]}, + 4: {"name": "DeepSeek Harness", "tip": "DeepSeek Harness(dsh): DeepSeek 官方的智能体框架(预览版)", + "pkg": "@deepseek-ai/dsh", "bin": "dsh", "node": (20, 0), + "usage": ["网页版: dsh web (启动后在浏览器打开它打印的 http://127.0.0.1:3080/?token=... 地址)", + "查看用法: dsh --help", "目前是预览版, 用法可能会变"]}, + 5: {"name": "Pi", "tip": "Pi: 极简、可扩展的命令行编程助手", + "pkg": "@earendil-works/pi-coding-agent", "bin": "pi", "node": (22, 19), + "usage": ["启动: pi"]}, +} +# 从 GitHub Releases 下载 deb 安装的图形界面工具. tag 是自动获取最新版失败时用的版本; +# file 里的 {tag} 是带 v 的版本号, {ver} 是不带 v 的; arch 是系统架构到安装包命名的对应 +DEB_APPS = { + 6: {"name": "CC Switch", "tip": "CC Switch: 图形界面, 一键切换 Claude Code/Codex 等工具的模型供应商", + "repo": "farion1231/cc-switch", "tag": "v4.0.24", "deb": "cc-switch", + "file": "CC-Switch-{tag}-Linux-{arch}.deb", "arch": {"amd64": "x86_64", "arm64": "arm64"}, + "usage": ["启动: 在应用菜单中搜索 CC Switch"]}, + 7: {"name": "Cockpit Tools", "tip": "Cockpit Tools: 图形界面, 管理多个AI编程工具的账号和额度", + "repo": "jlcodes99/cockpit-tools", "tag": "v1.3.78", "deb": "cockpit-tools", + "file": "Cockpit.Tools_{ver}_{arch}.deb", "arch": {"amd64": "amd64", "arm64": "arm64"}, + "usage": ["启动: 在应用菜单中搜索 Cockpit Tools"]}, +} + class Tool(BaseTool): NPM_MIRROR = "https://registry.npmmirror.com" @@ -13,9 +48,10 @@ class Tool(BaseTool): PREFERRED_NRM_ALIASES = ["taobao", "tencent", "huawei", "npm", "yarn", "cnpm"] def __init__(self): - self.name = "一键安装OpenCode" + self.name = "一键安装AI编程工具" self.type = BaseTool.TYPE_INSTALL self.author = "小鱼" + self.app = NPM_APPS[1] def _run_cmd(self, cmd, timeout=0, msg=None): if msg: @@ -95,24 +131,26 @@ def _fix_npm_prefix(self): return False return True - def _cleanup_bad_opencode_link(self): - target_result = self._run_cmd("readlink -f /usr/local/bin/opencode", 10) + def _cleanup_bad_link(self): + link = "/usr/local/bin/{}".format(self.app["bin"]) + target_result = self._run_cmd("readlink -f {}".format(link), 10) if self._code(target_result) != 0 or len(self._out(target_result)) == 0: return True target = str(self._out(target_result)[0]).strip() if target.startswith("/root/"): - PrintUtils.print_warn("检测到opencode链接指向/root,清理旧链接...") - rm_result = self._run_cmd("sudo rm -f /usr/local/bin/opencode", 10) + PrintUtils.print_warn("检测到{}链接指向/root,清理旧链接...".format(self.app["bin"])) + rm_result = self._run_cmd("sudo rm -f {}".format(link), 10) return self._code(rm_result) == 0 return True - def _ensure_opencode_command(self): - user_check = self._run_cmd("command -v opencode", 10) + def _ensure_command(self): + cmd = self.app["bin"] + user_check = self._run_cmd("command -v {}".format(cmd), 10) if self._code(user_check) == 0: return True prefix_result = self._run_cmd( - "sudo npm prefix -g", 10, "定位OpenCode安装目录..." + "sudo npm prefix -g", 10, "定位{}安装目录...".format(self.app["name"]) ) prefix_out = self._out(prefix_result) if self._code(prefix_result) != 0 or len(prefix_out) == 0: @@ -120,21 +158,24 @@ def _ensure_opencode_command(self): npm_prefix = str(prefix_out[0]).strip() if npm_prefix.startswith("/root"): - PrintUtils.print_warn("OpenCode安装目录位于/root,普通用户可能无法访问") + PrintUtils.print_warn("{}安装目录位于/root,普通用户可能无法访问".format(self.app["name"])) return False link_result = self._run_cmd( - "sudo ln -sf {}/bin/opencode /usr/local/bin/opencode".format(npm_prefix), + "sudo ln -sf {}/bin/{} /usr/local/bin/{}".format(npm_prefix, cmd, cmd), 10, - "写入opencode命令到系统PATH...", + "写入{}命令到系统PATH...".format(cmd), ) if self._code(link_result) != 0: return False - verify = self._run_cmd("command -v opencode", 10) + verify = self._run_cmd("command -v {}".format(cmd), 10) return self._code(verify) == 0 def _remove_local_opencode(self): + # 只有 OpenCode 有官方脚本装到 ~/.opencode 的情况 + if self.app["pkg"] != "opencode-ai": + return True local_dir = os.path.expanduser("~/.opencode") local_bin = os.path.join(local_dir, "bin", "opencode") if not os.path.exists(local_bin): @@ -153,25 +194,26 @@ def _remove_local_opencode(self): return True def _install_with_registry_fallback(self, registry_ready): + pkg, name = self.app["pkg"], self.app["name"] if registry_ready: install_cmds = [ ( - "sudo npm i -g opencode-ai", - "安装OpenCode中(使用已切换的npm源)...", + "sudo npm i -g {}".format(pkg), + "安装{}中(使用已切换的npm源)...".format(name), ), ( - "sudo npm i -g opencode-ai --registry={}".format(self.NPM_MIRROR), + "sudo npm i -g {} --registry={}".format(pkg, self.NPM_MIRROR), "当前npm源安装失败,使用国内镜像参数重试...", ), ] else: install_cmds = [ ( - "sudo npm i -g opencode-ai --registry={}".format(self.NPM_MIRROR), - "安装OpenCode中(优先使用国内npm镜像)...", + "sudo npm i -g {} --registry={}".format(pkg, self.NPM_MIRROR), + "安装{}中(优先使用国内npm镜像)...".format(name), ), ( - "sudo npm i -g opencode-ai", + "sudo npm i -g {}".format(pkg), "国内npm镜像安装失败,回退官方源重试...", ), ] @@ -181,14 +223,14 @@ def _install_with_registry_fallback(self, registry_ready): if self._code(result) == 0: return True if self._has_text(result, "ENOTEMPTY"): - PrintUtils.print_warn("检测到旧版OpenCode残留目录,开始自动清理后重试") - if self._cleanup_global_opencode(): - retry_result = self._run_cmd(cmd, 300, "重新安装OpenCode中...") + PrintUtils.print_warn("检测到旧版{}残留目录,开始自动清理后重试".format(name)) + if self._cleanup_global_install(): + retry_result = self._run_cmd(cmd, 300, "重新安装{}中...".format(name)) if self._code(retry_result) == 0: return True return False - def _cleanup_global_opencode(self): + def _cleanup_global_install(self): prefix_result = self._run_cmd("sudo npm prefix -g", 10, "定位npm全局目录...") prefix_out = self._out(prefix_result) if self._code(prefix_result) != 0 or len(prefix_out) == 0: @@ -197,16 +239,19 @@ def _cleanup_global_opencode(self): npm_prefix = str(prefix_out[0]).strip() module_root = "{}/lib/node_modules".format(npm_prefix) - cleanup_cmd = ( - "sudo rm -rf " - "{root}/opencode-ai " - "{root}/.opencode-ai-* " - "/usr/local/bin/opencode" - ).format(root=module_root) + # npm 升级中断会留下「.包名-随机串」临时目录; 带 scope 的包(@openai/codex)临时目录在 scope 目录里 + pkg = self.app["pkg"] + scope, _, base = pkg.rpartition("/") + if scope: + tmp_dir = "{}/{}/.{}-*".format(module_root, scope, base) + else: + tmp_dir = "{}/.{}-*".format(module_root, base) + cleanup_cmd = "sudo rm -rf {root}/{pkg} {tmp} /usr/local/bin/{bin}".format( + root=module_root, pkg=pkg, tmp=tmp_dir, bin=self.app["bin"]) cleanup_result = self._run_cmd( cleanup_cmd, 30, - "清理OpenCode全局残留目录...", + "清理{}全局残留目录...".format(self.app["name"]), ) return self._code(cleanup_result) == 0 @@ -321,28 +366,41 @@ def _switch_npm_registry(self): self._run_cmd("npm config get registry", 10, "当前npm源:") return False + def _node_version(self): + result = self._run_cmd("node -v", 10, "检查Node.js版本...") + match = re.match(r"v(\d+)\.(\d+)", " ".join(self._out(result)).strip()) + if self._code(result) != 0 or not match: + return None + return int(match.group(1)), int(match.group(2)) + def _ensure_nodejs(self): - check_node = self._run_cmd("node -v", 10, "检查Node.js版本...") + need = self.app["node"] + version = self._node_version() check_npm = self._run_cmd("npm -v", 10, "检查npm版本...") check_sudo_npm = self._run_cmd("sudo npm -v", 10, "检查sudo环境npm...") if ( - self._code(check_node) == 0 + version is not None and self._code(check_npm) == 0 and self._code(check_sudo_npm) == 0 ): - return True - PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 24") + if version >= need: + return True + PrintUtils.print_warn("{}需要Node.js {}.{}以上, 当前是 {}.{}, 将自动安装Node.js 24".format( + self.app["name"], need[0], need[1], version[0], version[1])) + else: + PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 24") return NodejsTool().install_nodejs_version(24, with_registry=False) - def install_opencode(self): + def install_npm_app(self): + name, cmd = self.app["name"], self.app["bin"] if not self._ensure_nodejs(): - PrintUtils.print_error("Node.js环境准备失败,无法安装OpenCode") + PrintUtils.print_error("Node.js环境准备失败,无法安装{}".format(name)) return False if not self._fix_npm_prefix(): PrintUtils.print_warn("npm全局目录修复失败,安装可能受影响") - if not self._cleanup_bad_opencode_link(): - PrintUtils.print_warn("旧版opencode命令链接清理失败") + if not self._cleanup_bad_link(): + PrintUtils.print_warn("旧版{}命令链接清理失败".format(cmd)) self._print_runtime_diagnostics() @@ -351,73 +409,123 @@ def install_opencode(self): PrintUtils.print_warn("npm源切换失败,将直接使用镜像参数继续安装") if not self._install_with_registry_fallback(registry_ready): - PrintUtils.print_error("OpenCode安装失败,请检查npm网络、镜像可用性和权限") + PrintUtils.print_error("{}安装失败,请检查npm网络、镜像可用性和权限".format(name)) return False - if not self._ensure_opencode_command(): - PrintUtils.print_warn("未能自动修复opencode命令路径,将继续进行安装校验") + if not self._ensure_command(): + PrintUtils.print_warn("未能自动修复{}命令路径,将继续进行安装校验".format(cmd)) - verify = self._run_cmd("opencode --version", 10, "验证OpenCode版本...") + verify = self._run_cmd("{} --version".format(cmd), 10, "验证{}版本...".format(name)) if self._code(verify) != 0: verify = self._run_cmd( - "sudo npm list -g opencode-ai --depth=0", + "sudo npm list -g {} --depth=0".format(self.app["pkg"]), 20, - "验证OpenCode安装状态...", + "验证{}安装状态...".format(name), ) if self._code(verify) != 0: - PrintUtils.print_error("OpenCode验证失败,请重新执行安装") + PrintUtils.print_error("{}验证失败,请重新执行安装".format(name)) return False - PrintUtils.print_success("OpenCode安装完成") - PrintUtils.print_info("可通过命令 opencode 启动") - PrintUtils.print_info("可通过命令 opencode web 打开网页使用") + PrintUtils.print_success("{}安装完成".format(name)) + for line in self.app["usage"]: + PrintUtils.print_info(line) return True - def uninstall_opencode(self): + def uninstall_npm_app(self): + name, cmd, pkg = self.app["name"], self.app["bin"], self.app["pkg"] self._run_cmd( - "sudo rm -f /usr/local/bin/opencode", 10, "清理opencode命令链接..." + "sudo rm -f /usr/local/bin/{}".format(cmd), 10, "清理{}命令链接...".format(cmd) ) local_removed = self._remove_local_opencode() npm_installed = self._run_cmd( - "sudo npm list -g opencode-ai --depth=0", + "sudo npm list -g {} --depth=0".format(pkg), 20, - "检查npm全局OpenCode安装...", + "检查npm全局{}安装...".format(name), ) if self._code(npm_installed) == 0: uninstall_result = self._run_cmd( - "sudo npm uninstall -g opencode-ai", + "sudo npm uninstall -g {}".format(pkg), 120, - "卸载OpenCode中...", + "卸载{}中...".format(name), ) if self._code(uninstall_result) != 0: - PrintUtils.print_error("npm全局OpenCode卸载失败,请检查npm权限或网络") + PrintUtils.print_error("npm全局{}卸载失败,请检查npm权限或网络".format(name)) return False else: - PrintUtils.print_info("未检测到npm全局安装的OpenCode,跳过npm卸载") + PrintUtils.print_info("未检测到npm全局安装的{},跳过npm卸载".format(name)) - verify = self._run_cmd("opencode --version", 10, "验证OpenCode是否已卸载...") + verify = self._run_cmd("command -v {}".format(cmd), 10, "验证{}是否已卸载...".format(name)) if self._code(verify) == 0: PrintUtils.print_warn( - "检测到opencode命令仍存在,请检查PATH中的其他安装位置" + "检测到{}命令仍存在,请检查PATH中的其他安装位置".format(cmd) ) return False if not local_removed: return False - PrintUtils.print_success("OpenCode卸载完成") + PrintUtils.print_success("{}卸载完成".format(name)) + return True + + def _deb_installed(self, pkg): + result = self._run_cmd("dpkg-query -W -f='${Status}' " + pkg, 10) + return "install ok installed" in " ".join(self._out(result)) + + def install_deb_app(self): + app = self.app + if osarch not in app["arch"]: + PrintUtils.print_error("{}暂不支持当前系统架构:{}".format(app["name"], osarch)) + return False + PrintUtils.print_info("正在获取{}最新版本...".format(app["name"])) + tag = DownloadUtils.github_latest_tag(app["repo"]) + if tag is None: + tag = app["tag"] + PrintUtils.print_warn("获取最新版本失败, 安装已知版本 {}".format(tag)) + file_name = app["file"].format(tag=tag, ver=tag.lstrip("v"), arch=app["arch"][osarch]) + PrintUtils.print_info("准备安装: {} {}".format(app["name"], tag)) + temp_file = "/tmp/fishros_ai_tool.deb" + if not DownloadUtils.download_deb(DownloadUtils.github_sources(app["repo"], tag, file_name), temp_file): + self._run_cmd('rm -f "{}"'.format(temp_file), 10) + PrintUtils.print_error("{}下载失败,请检查网络后重试".format(app["name"])) + return False + # 用 apt 装本地包, 缺的依赖(webkit2gtk 等)会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "{}"'.format(temp_file), 600, os_command=True).run() + self._run_cmd('rm -f "{}"'.format(temp_file), 10) + if not self._deb_installed(app["deb"]): + PrintUtils.print_error("{}安装失败(Ubuntu 22.04以下的系统可能缺少依赖), 请把上面的报错信息发到鱼香社区求助".format(app["name"])) + return False + PrintUtils.print_success("{}安装完成".format(app["name"])) + for line in app["usage"]: + PrintUtils.print_info(line) + return True + + def uninstall_deb_app(self): + app = self.app + if not self._deb_installed(app["deb"]): + PrintUtils.print_info("未检测到已安装的{}".format(app["name"])) + return True + CmdTask("sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y {}".format(app["deb"]), 300, os_command=True).run() + if self._deb_installed(app["deb"]): + PrintUtils.print_error("{}卸载失败".format(app["name"])) + return False + PrintUtils.print_success("{}卸载完成".format(app["name"])) return True def run(self): - action_dic = { - 1: "安装OpenCode", - 2: "卸载OpenCode", - } - code, _ = ChooseTask(action_dic, "请选择操作:", False).run() - if code == 1: - self.install_opencode() - elif code == 2: - self.uninstall_opencode() - else: - PrintUtils.print_warn("已取消OpenCode操作") + apps = dict(NPM_APPS) + apps.update(DEB_APPS) + while True: + code, _ = ChooseTask({k: v["tip"] for k, v in apps.items()}, "请选择AI编程工具:", False).run() + if code == 0: + return BaseTool.BACK + if code not in apps: + continue + self.app = apps[code] + action, _ = ChooseTask({1: "安装" + self.app["name"], 2: "卸载" + self.app["name"]}, "请选择操作:", False).run() + if action == 0: + continue + is_deb = code in DEB_APPS + if action == 1: + return self.install_deb_app() if is_deb else self.install_npm_app() + return self.uninstall_deb_app() if is_deb else self.uninstall_npm_app() diff --git a/tools/tool_install_proxy_tool.py b/tools/tool_install_proxy_tool.py index 3f52818..e194df6 100644 --- a/tools/tool_install_proxy_tool.py +++ b/tools/tool_install_proxy_tool.py @@ -1,14 +1,9 @@ # -*- coding: utf-8 -*- -import collections import os -import shutil -import sys -import time -import urllib.request from urllib.parse import quote from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, ChooseTask +from .base import PrintUtils, CmdTask, FileUtils, ChooseTask, DownloadUtils from .base import osarch # 可选的客户端. port 是各客户端默认的混合代理端口, 写进 proxy_add 快捷命令 @@ -36,17 +31,6 @@ }, } -# 国内直连 GitHub 基本下不动, 按顺序尝试: 两个公共 GitHub 加速站(快但时好时坏, 有时会拒绝某些仓库), -# 最后用 repo.trojan-cdn.com 兜底(一直能下, 但国内只有十几 KB/s). 2026-11 在国内服务器实测 -DOWNLOAD_SOURCES = [ - ("ghfast.top 加速", "https://ghfast.top/https://github.com/{repo}/releases/download/{tag}/{file}"), - ("gh-proxy.com 加速", "https://gh-proxy.com/https://github.com/{repo}/releases/download/{tag}/{file}"), - ("trojan-cdn 镜像", "https://repo.trojan-cdn.com/{cdn_dir}/{file}"), -] -# 每个源先下 15 秒, 平均速度达到 200KB/s 就用它下完; 都达不到时, 用测下来最快的那个源下完 -# (不能简单地「慢了就换下一个」: 实测 gh-proxy.com 89KB/s 被放弃, 最后落到只有 15KB/s 的 trojan-cdn, 反而更慢) -SLOW_CHECK_SECONDS = 15 -SLOW_SPEED = 200 * 1024 class Tool(BaseTool): @@ -83,92 +67,13 @@ def _choose_install_mode(self): code, _ = ChooseTask(mode_dic, "请选择代理工具(不知道选哪个就选1):", False).run() return code - @staticmethod - def _format_speed(speed): - if speed >= 1048576: - return "{:.1f}MB/s".format(speed / 1048576.0) - return "{:.0f}KB/s".format(speed / 1024.0) - - @staticmethod - def _draw_progress(done, total, speed): - """在同一行画进度条: [████████░░░░░░░░] 45% 17.5/38.7MB 2.4MB/s 剩余 9s""" - if total: - left = (total - done) / speed if speed > 0 else -1 - eta = "--" if left < 0 else ("{}m{:02d}s".format(int(left) // 60, int(left) % 60) if left >= 60 else "{}s".format(int(left))) - info = " {:3.0f}% {:.1f}/{:.1f}MB {} 剩余 {}".format( - done * 100.0 / total, done / 1048576.0, total / 1048576.0, Tool._format_speed(speed), eta) - # 进度条宽度随终端宽度变化; info 里的「剩余」两个汉字各占 2 列, 多留几列余量免得折行 - width = max(10, min(40, shutil.get_terminal_size((80, 20)).columns - len(info) - 10)) - filled = int(width * done / total) - line = " [{}{}]{}".format("█" * filled, "░" * (width - filled), info) - else: - line = " 已下载 {:.1f}MB {}".format(done / 1048576.0, Tool._format_speed(speed)) - sys.stdout.write("\r" + line + " ") - sys.stdout.flush() - - @staticmethod - def _download_from(url, path, give_up_if_slow): - """ - 下载到 path, 返回 (是否下完, 平均速度 B/s). give_up_if_slow 时, 下了 SLOW_CHECK_SECONDS 秒平均速度还太慢就放弃. - 不走环境变量里的代理(和原来 wget --no-proxy 一致), 超时 15 秒 - """ - speed = 0 - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - request = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21"}) - # 在终端里每 0.2 秒刷新一次; 输出被重定向到文件(日志、自动化测试)时 5 秒一次, 免得刷屏 - interval = 0.2 if sys.stdout.isatty() else 5 - try: - with opener.open(request, timeout=15) as resp, open(path, "wb") as f: - total = int(resp.headers.get("Content-Length") or 0) - done, start, last_print = 0, time.time(), 0 - recent = collections.deque([(start, 0)]) # 最近约 2 秒的 (时间, 已下载), 算实时速度 - while True: - chunk = resp.read(64 * 1024) - if not chunk: - break - f.write(chunk) - done += len(chunk) - now = time.time() - used = now - start - speed = done / max(used, 0.001) # 平均速度, 用来判断这个源快不快 - recent.append((now, done)) - while len(recent) > 2 and now - recent[0][0] > 2: - recent.popleft() - if now - last_print >= interval: - last_print = now - span = now - recent[0][0] - Tool._draw_progress(done, total, (done - recent[0][1]) / span if span > 0 else speed) - if give_up_if_slow and used > SLOW_CHECK_SECONDS and speed < SLOW_SPEED: - print("") - PrintUtils.print_warn("速度较慢({}), 先试试下一个下载源".format(Tool._format_speed(speed))) - return False, speed - Tool._draw_progress(done, total, speed) - print("") - return (total == 0 or done == total), speed - except Exception as e: - print("") - PrintUtils.print_warn("下载失败: {}".format(e)) - return False, 0 - def _download_package(self, client, path): - """按 DOWNLOAD_SOURCES 的顺序尝试, 下到完整的 deb 就返回 True""" - params = dict(client, file=client["file"].format(arch=osarch)) - slow_sources = [] - for source_name, template in DOWNLOAD_SOURCES: - url = template.format(**params) - PrintUtils.print_info("从 {} 下载: {}".format(source_name, url)) - ok, speed = self._download_from(url, path, give_up_if_slow=True) - if ok and self._code(CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()) == 0: - return True - if speed > 0: - slow_sources.append((speed, source_name, url)) - # 没有一个源够快: 用测下来最快的那个下完 - for speed, source_name, url in sorted(slow_sources, reverse=True): - PrintUtils.print_warn("几个下载源都比较慢, 改用其中最快的 {}({}) 下完, 请耐心等待".format(source_name, self._format_speed(speed))) - ok, _ = self._download_from(url, path, give_up_if_slow=False) - if ok and self._code(CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()) == 0: - return True - return False + """GitHub 加速站在前, repo.trojan-cdn.com 兜底(一直能下, 但国内只有十几 KB/s)""" + file_name = client["file"].format(arch=osarch) + sources = DownloadUtils.github_sources(client["repo"], client["tag"], file_name) + sources = [s for s in sources if s[0] != "GitHub 直连"] # 直连基本下不动, 用 trojan-cdn 兜底 + sources.append(("trojan-cdn 镜像", "https://repo.trojan-cdn.com/{}/{}".format(client["cdn_dir"], file_name))) + return DownloadUtils.download_deb(sources, path) def _install_deb_package(self, client): temp_file = "/tmp/fishros_proxy_tool.deb" From d3cb0aa5d8fc6a13685ea87eb53ae301397754d8 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:33:14 -0800 Subject: [PATCH 39/45] =?UTF-8?q?feat:=20AI=20=E7=BC=96=E7=A8=8B=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=A3=85=E5=AE=8C=E7=BB=99=E5=87=BA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E;=20=E6=96=B0=E5=A2=9E=E8=8F=9C=E5=8D=95=2023?= =?UTF-8?q?=20=E4=B8=80=E9=94=AE=E5=AE=89=E8=A3=85=20Chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 菜单 18 各工具装完显示「怎么启动 + 第一次用怎么配置」, 命令行工具统一提醒要先接入模型账号或 API Key; dsh 写明必须用 dsh web、要打开带 token 的地址、浏览器太老报 Intl.Segmenter 时改用 Chrome - 新增菜单 23(日常软件): 从 Google 官方下载 Chrome deb 安装(只有 amd64), 已安装则提示版本. 国内连不上 snap 商店, Ubuntu 22.04 的 snap 版 Firefox 停在旧版无法更新, 打不开一些新网页 Co-Authored-By: Claude Opus 5.5 --- install.py | 6 ++++ tools/tool_install_chrome.py | 52 ++++++++++++++++++++++++++++++++++ tools/tool_install_opencode.py | 31 ++++++++++++++------ 3 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 tools/tool_install_chrome.py diff --git a/install.py b/install.py index 3134261..aeece2c 100644 --- a/install.py +++ b/install.py @@ -165,6 +165,12 @@ "tool": "tools/tool_install_qq.py", "dep": [], }, + 23: { + "tip": "一键安装:Chrome浏览器(系统自带的Firefox在国内常无法更新时使用)", + "type": CAT_DAILY, + "tool": "tools/tool_install_chrome.py", + "dep": [], + }, } diff --git a/tools/tool_install_chrome.py b/tools/tool_install_chrome.py new file mode 100644 index 0000000..7e0c4bf --- /dev/null +++ b/tools/tool_install_chrome.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from .base import BaseTool +from .base import PrintUtils, CmdTask, DownloadUtils +from .base import osarch + +# Google 官方 deb, 国内直连实测约 8MB/s(2026-11). 装上后会自动加 Google 的 apt 源, 之后 apt upgrade 就能升级 +CHROME_URL = "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" +CHROME_PKG = "google-chrome-stable" + + +class Tool(BaseTool): + def __init__(self): + self.type = BaseTool.TYPE_INSTALL + self.name = "一键安装Chrome浏览器" + self.author = '小鱼' + + @staticmethod + def installed_version(): + result = CmdTask("dpkg-query -W -f='${Status} ${Version}' " + CHROME_PKG).run() + status = " ".join(result[1]) + if result[0] == 0 and "install ok installed" in status: + return status.replace("install ok installed", "").strip() + return None + + def install_chrome(self): + # Ubuntu 22.04 自带的 snap 版 Firefox 在国内连不上 snap 商店、没法更新, 太老的版本打不开一些新网页(如 dsh web) + if osarch != "amd64": + PrintUtils.print_error("Google 只提供 amd64 版的 Linux Chrome, 当前系统架构是 {}, 请使用系统自带的浏览器".format(osarch)) + return False + version = self.installed_version() + if version: + PrintUtils.print_success("已经安装了 Chrome {}, 以后用 sudo apt update && sudo apt upgrade 即可升级".format(version)) + return True + PrintUtils.print_info("开始下载 Chrome 浏览器(约140MB)~") + temp_file = "/tmp/fishros_chrome.deb" + if not DownloadUtils.download_deb([("Google 官方", CHROME_URL)], temp_file): + CmdTask('rm -f "{}"'.format(temp_file)).run() + PrintUtils.print_error("Chrome 下载失败, 请检查网络后重试") + return False + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "{}"'.format(temp_file), 600, os_command=True).run() + CmdTask('rm -f "{}"'.format(temp_file)).run() + version = self.installed_version() + if not version: + PrintUtils.print_error("Chrome 安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("Chrome {} 安装完成".format(version)) + PrintUtils.print_info("启动: 在应用菜单中搜索 Chrome, 或终端运行 google-chrome") + return True + + def run(self): + return self.install_chrome() diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index 6203482..1dae209 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -12,32 +12,44 @@ NPM_APPS = { 1: {"name": "OpenCode", "tip": "OpenCode: 开源AI编程助手(命令行/网页)", "pkg": "opencode-ai", "bin": "opencode", "node": (20, 0), - "usage": ["启动: opencode", "网页版: opencode web"]}, + "usage": ["1.启动: 在项目目录下运行 opencode (网页版: opencode web)", + "2.第一次用: 在界面里输入 /connect, 选择模型供应商并填入 API Key(DeepSeek、Kimi、智谱等国内的也支持)"]}, 2: {"name": "Codex", "tip": "Codex: OpenAI 的命令行编程助手", "pkg": "@openai/codex", "bin": "codex", "node": (16, 0), - "usage": ["启动: codex (首次使用按提示登录 OpenAI 账号或配置 API Key)"]}, + "usage": ["1.启动: 在项目目录下运行 codex", + "2.第一次用: 按提示登录 ChatGPT 账号或填 OpenAI API Key; 想用国内模型, 可以用菜单[18]->[6] CC Switch 一键配置"]}, 3: {"name": "Claude Code", "tip": "Claude Code: Anthropic 的命令行编程助手", "pkg": "@anthropic-ai/claude-code", "bin": "claude", "node": (22, 0), - "usage": ["启动: claude (需要 Anthropic 账号, 或用 CC Switch 切换到其它兼容的模型供应商)"]}, + "usage": ["1.启动: 在项目目录下运行 claude", + "2.第一次用: 登录 Anthropic 账号; 想用国内模型(DeepSeek 等提供的 Anthropic 兼容接口), 可以用菜单[18]->[6] CC Switch 一键配置"]}, 4: {"name": "DeepSeek Harness", "tip": "DeepSeek Harness(dsh): DeepSeek 官方的智能体框架(预览版)", "pkg": "@deepseek-ai/dsh", "bin": "dsh", "node": (20, 0), - "usage": ["网页版: dsh web (启动后在浏览器打开它打印的 http://127.0.0.1:3080/?token=... 地址)", - "查看用法: dsh --help", "目前是预览版, 用法可能会变"]}, + "usage": ["1.启动: 在项目目录下运行 dsh web (直接输入 dsh 会报 --profile is required)", + "2.在浏览器里打开它打印的完整地址 http://127.0.0.1:3080/?token=... (要带上 token), 在网页里配置 DeepSeek API Key", + "3.网页需要较新的浏览器: Ubuntu 22.04 自带的 snap 版 Firefox 在国内一般没法更新,", + " 打开后报 Intl.Segmenter is not a constructor 的话, 请改用 Chrome(一键安装主菜单[23]可安装)", + "4.不开网页, 单次执行任务: dsh --profile headless \"要做的事\"; 更多用法: dsh --help", + "5.目前是预览版, 用法可能会变"]}, 5: {"name": "Pi", "tip": "Pi: 极简、可扩展的命令行编程助手", "pkg": "@earendil-works/pi-coding-agent", "bin": "pi", "node": (22, 19), - "usage": ["启动: pi"]}, + "usage": ["1.启动: 在项目目录下运行 pi", + "2.第一次用: 在界面里输入 /login 登录模型账号(或先设好 API Key 环境变量), 再用 /model 选择模型"]}, } +# 命令行工具都要先接入模型账号或 API Key 才能干活, 装完统一提醒一句 +NPM_COMMON_USAGE = "提示: 这些 AI 编程工具都要先接入模型账号或 API Key 才能使用, 打开界面后按上面的第一次使用说明配置" # 从 GitHub Releases 下载 deb 安装的图形界面工具. tag 是自动获取最新版失败时用的版本; # file 里的 {tag} 是带 v 的版本号, {ver} 是不带 v 的; arch 是系统架构到安装包命名的对应 DEB_APPS = { 6: {"name": "CC Switch", "tip": "CC Switch: 图形界面, 一键切换 Claude Code/Codex 等工具的模型供应商", "repo": "farion1231/cc-switch", "tag": "v4.0.24", "deb": "cc-switch", "file": "CC-Switch-{tag}-Linux-{arch}.deb", "arch": {"amd64": "x86_64", "arm64": "arm64"}, - "usage": ["启动: 在应用菜单中搜索 CC Switch"]}, + "usage": ["1.启动: 在应用菜单中搜索 CC Switch, 或终端运行 cc-switch", + "2.添加模型供应商(填 API 地址和 Key), 然后一键切换 Claude Code、Codex 等工具使用的供应商"]}, 7: {"name": "Cockpit Tools", "tip": "Cockpit Tools: 图形界面, 管理多个AI编程工具的账号和额度", "repo": "jlcodes99/cockpit-tools", "tag": "v1.3.78", "deb": "cockpit-tools", "file": "Cockpit.Tools_{ver}_{arch}.deb", "arch": {"amd64": "amd64", "arm64": "arm64"}, - "usage": ["启动: 在应用菜单中搜索 Cockpit Tools"]}, + "usage": ["1.启动: 在应用菜单中搜索 Cockpit Tools", + "2.导入各个 AI 编程工具的账号后, 可以查看额度、一键切换账号"]}, } @@ -427,8 +439,10 @@ def install_npm_app(self): return False PrintUtils.print_success("{}安装完成".format(name)) + PrintUtils.print_info("==========使用方法===========") for line in self.app["usage"]: PrintUtils.print_info(line) + PrintUtils.print_warn(NPM_COMMON_USAGE) return True def uninstall_npm_app(self): @@ -496,6 +510,7 @@ def install_deb_app(self): PrintUtils.print_error("{}安装失败(Ubuntu 22.04以下的系统可能缺少依赖), 请把上面的报错信息发到鱼香社区求助".format(app["name"])) return False PrintUtils.print_success("{}安装完成".format(app["name"])) + PrintUtils.print_info("==========使用方法===========") for line in app["usage"]: PrintUtils.print_info(line) return True From 30ac98eaba744014fe6cc0c16a2c8fd5e70e7915 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:38:25 -0800 Subject: [PATCH 40/45] =?UTF-8?q?docs(ai-tools):=20dsh=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=A1=A5=E5=85=85=E4=BC=9A=E8=87=AA=E5=8A=A8=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=BB=98=E8=AE=A4=E6=B5=8F=E8=A7=88=E5=99=A8=E3=80=81?= =?UTF-8?q?=E8=A6=81=E5=85=88=E9=80=89=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dsh web 会自动用默认浏览器打开, 没打开再复制带 token 的地址 - 打开后要点「请选择模型」配置 DeepSeek API Key; 工作区是运行 dsh web 时的目录 - 默认浏览器是旧 Firefox 时报 Intl.Segmenter, 提示装 Chrome 并设为默认浏览器 Co-Authored-By: Claude Opus 5.5 --- tools/tool_install_opencode.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index 1dae209..b03c405 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -25,9 +25,11 @@ 4: {"name": "DeepSeek Harness", "tip": "DeepSeek Harness(dsh): DeepSeek 官方的智能体框架(预览版)", "pkg": "@deepseek-ai/dsh", "bin": "dsh", "node": (20, 0), "usage": ["1.启动: 在项目目录下运行 dsh web (直接输入 dsh 会报 --profile is required)", - "2.在浏览器里打开它打印的完整地址 http://127.0.0.1:3080/?token=... (要带上 token), 在网页里配置 DeepSeek API Key", + "2.它会自动用默认浏览器打开网页; 没打开的话, 把终端里打印的完整地址 http://127.0.0.1:3080/?token=... 复制到浏览器(要带上 token)", + " 打开后点「请选择模型」, 配置 DeepSeek API Key 才能对话; 工作区就是运行 dsh web 时所在的目录", "3.网页需要较新的浏览器: Ubuntu 22.04 自带的 snap 版 Firefox 在国内一般没法更新,", - " 打开后报 Intl.Segmenter is not a constructor 的话, 请改用 Chrome(一键安装主菜单[23]可安装)", + " 打开后报 Intl.Segmenter is not a constructor 的话, 请改用 Chrome(一键安装主菜单[23]可安装),", + " 并把 Chrome 设为默认浏览器(设置 -> 默认应用), 或者把地址复制到 Chrome 里打开", "4.不开网页, 单次执行任务: dsh --profile headless \"要做的事\"; 更多用法: dsh --help", "5.目前是预览版, 用法可能会变"]}, 5: {"name": "Pi", "tip": "Pi: 极简、可扩展的命令行编程助手", From ca9579658b8c5a1b2e4e20ddc7037c0d1e91b707 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:55:03 -0800 Subject: [PATCH 41/45] =?UTF-8?q?docs(chrome):=20=E8=8F=9C=E5=8D=95=2023?= =?UTF-8?q?=20=E6=94=B9=E4=B8=BA=E5=BC=BA=E7=83=88=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E8=AE=BE=E4=B8=BA=E9=BB=98=E8=AE=A4=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?,=20=E8=A3=85=E5=AE=8C=E6=8F=90=E7=A4=BA=E6=80=8E=E4=B9=88?= =?UTF-8?q?=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- install.py | 2 +- tools/tool_install_chrome.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index aeece2c..5280fd3 100644 --- a/install.py +++ b/install.py @@ -166,7 +166,7 @@ "dep": [], }, 23: { - "tip": "一键安装:Chrome浏览器(系统自带的Firefox在国内常无法更新时使用)", + "tip": "一键安装:Chrome浏览器(强烈推荐下载设置成默认浏览器)", "type": CAT_DAILY, "tool": "tools/tool_install_chrome.py", "dep": [], diff --git a/tools/tool_install_chrome.py b/tools/tool_install_chrome.py index 7e0c4bf..aa49e8d 100644 --- a/tools/tool_install_chrome.py +++ b/tools/tool_install_chrome.py @@ -46,6 +46,7 @@ def install_chrome(self): return False PrintUtils.print_success("Chrome {} 安装完成".format(version)) PrintUtils.print_info("启动: 在应用菜单中搜索 Chrome, 或终端运行 google-chrome") + PrintUtils.print_info("推荐设为默认浏览器: 首次打开 Chrome 时按提示设置, 或在 设置 -> 默认应用 -> 网页 里选 Google Chrome") return True def run(self): From 4ed7773672e41183e8825ca2cad064a44a5d74b6 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:47:53 -0800 Subject: [PATCH 42/45] =?UTF-8?q?feat(proxy):=20[14]=20=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E9=93=BE=E6=8E=A5=E6=94=B9=E4=B8=BA=E4=BB=8E?= =?UTF-8?q?=E5=AE=98=E7=BD=91=E5=90=8E=E5=8F=B0=E8=AF=BB=E5=8F=96,=20?= =?UTF-8?q?=E8=AF=BB=E4=B8=8D=E5=88=B0=E6=97=B6=E7=94=A8=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base.py DownloadUtils 旁新增 AdUtils.links + AdUtils.link(name, default), 从 /api/install/ads 的 links 段读可配置链接 - [14] 装完提示的订阅注册链接改为 AdUtils.link("proxy_subscribe", 默认); 官网后台留空则不推荐 - 默认值从已删的论坛帖 topic/668 换成 portal.shadowsocks.nz 注册链接 Co-Authored-By: Claude Opus 4.8 --- tools/base.py | 13 ++++++++++++- tools/tool_install_proxy_tool.py | 25 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tools/base.py b/tools/base.py index 855d90b..9f6b1f5 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1919,6 +1919,8 @@ class AdUtils: }, } scenes = None + # 官网后台可配置的链接, 如 proxy_subscribe(代理工具推荐的订阅注册链接); 接口读不到时为空, 由调用方用默认值 + links = {} shown_scenes = set() failed = False @@ -1942,11 +1944,20 @@ def load(cls): data = resp.read() if resp.headers.get("Content-Encoding") == "gzip": data = gzip.decompress(data) - cls.scenes.update(json.loads(data.decode("utf-8")).get("scenes", {})) + data = json.loads(data.decode("utf-8")) + cls.scenes.update(data.get("scenes", {})) + cls.links = data.get("links") or {} except Exception: pass return cls.scenes + @classmethod + def link(cls, name, default): + """取官网后台配置的链接; 后台没配或接口读不到用 default, 后台特意留空则返回空字符串""" + cls.load() + value = cls.links.get(name) + return default if value is None else value + @classmethod def mark_failed(cls): cls.failed = True diff --git a/tools/tool_install_proxy_tool.py b/tools/tool_install_proxy_tool.py index e194df6..f55db7c 100644 --- a/tools/tool_install_proxy_tool.py +++ b/tools/tool_install_proxy_tool.py @@ -3,7 +3,7 @@ from urllib.parse import quote from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, ChooseTask, DownloadUtils +from .base import PrintUtils, CmdTask, FileUtils, ChooseTask, DownloadUtils, AdUtils from .base import osarch # 可选的客户端. port 是各客户端默认的混合代理端口, 写进 proxy_add 快捷命令 @@ -34,7 +34,9 @@ class Tool(BaseTool): - RECOMMEND_SUBSCRIBE_URL = "https://fishros.org.cn/forum/topic/668" + # 推荐的订阅注册链接在官网后台隐藏页 /admin/install/aff 里配置, 读不到官网时用这个默认值. + # (原来指向论坛帖子 topic/668, 该帖 2024-10 已删除; 帖子里的 .au 注册地址国内也已连不上) + DEFAULT_SUBSCRIBE_URL = "https://portal.shadowsocks.nz/aff.php?aff=41638" def __init__(self): self.name = "一键安装 Linux 代理科学上网工具" @@ -120,10 +122,15 @@ def _install_deb_package(self, client): self._run_cmd('rm -f "{}"'.format(temp_file), 10) return True + def _recommend_url(self): + return AdUtils.link("proxy_subscribe", self.DEFAULT_SUBSCRIBE_URL) + def _read_input_subscription(self): - PrintUtils.print_warn( - "请输入订阅地址(若无请访问 {} 获取)".format(self.RECOMMEND_SUBSCRIBE_URL) - ) + recommend = self._recommend_url() + if recommend: + PrintUtils.print_warn("请输入订阅地址(若无请访问 {} 获取)".format(recommend)) + else: + PrintUtils.print_warn("请输入订阅地址") return input("订阅地址:").strip() def _auto_import_subscription(self, subscribe_url): @@ -230,12 +237,12 @@ def install_proxy_tool(self): subscribe_url = self._read_input_subscription() if subscribe_url: self._auto_import_subscription(subscribe_url) - else: + elif self._recommend_url(): PrintUtils.print_warn( - "未输入订阅地址,可稍后在客户端导入,推荐: {}".format( - self.RECOMMEND_SUBSCRIBE_URL - ) + "未输入订阅地址,可稍后在客户端导入,推荐: {}".format(self._recommend_url()) ) + else: + PrintUtils.print_warn("未输入订阅地址,可稍后在客户端导入") user_homes = FileUtils.getusershome() if len(user_homes) > 0: From 82c3f8135404940bccc2c3be67193a774f725336 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:38:35 -0800 Subject: [PATCH 43/45] =?UTF-8?q?fix(proxy):=20=E8=A3=85=E5=AE=8C=E7=BB=99?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E9=A1=B9=E6=B3=A8=E5=85=A5=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E4=BF=AE=E8=99=9A=E6=8B=9F=E6=9C=BA=E7=99=BD?= =?UTF-8?q?=E5=B1=8F;=20=E6=94=B9=E6=8E=A8=E8=8D=90=20Clash=20Verge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 虚拟机(VMware/VirtualBox)显卡上 Clash Verge(WebKitGTK)白屏: 装完给它的 .desktop Exec 前加 WEBKIT_DISABLE_DMABUF_RENDERER=1(幂等), 点图标启动即正常; 真机加上无副作用 - FlClash(Flutter)在这类虚拟机显卡上白屏且无可靠开关, 故推荐默认改为 Clash Verge Rev (编号不变, 只改推荐标记和默认提示); FlClash 保留作备选并注明虚拟机可能白屏 - 136 VMware 实测: Clash Verge 加该变量后界面正常; .desktop 注入幂等 Co-Authored-By: Claude Opus 4.8 --- tools/tool_install_proxy_tool.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tools/tool_install_proxy_tool.py b/tools/tool_install_proxy_tool.py index f55db7c..b5fc1c3 100644 --- a/tools/tool_install_proxy_tool.py +++ b/tools/tool_install_proxy_tool.py @@ -11,23 +11,30 @@ CLIENTS = { 1: { "name": "FlClash", - "tip": "FlClash(推荐): 有界面, 安装包最小(约40MB)", + "tip": "FlClash: 有界面, 安装包最小(约40MB); 虚拟机里可能白屏, 建议改用 Clash Verge", "repo": "chen08209/FlClash", "tag": "v0.9.2", "cdn_dir": "FlClash/v0.9.2", "file": "FlClash-0.9.2-linux-{arch}.deb", "port": 7890, "cmds": ["FlClash", "flclash"], + "deb": "flclash", + # Flutter 应用在虚拟机的 vmware/virtualbox 显卡上默认渲染会白屏, 待确认有效的开关后再填 + "desktop_env": "", }, 2: { "name": "Clash Verge Rev", - "tip": "Clash Verge Rev: 有界面(约100MB)", + "tip": "Clash Verge Rev(推荐): 有界面(约100MB), 虚拟机/真机都能正常显示", "repo": "clash-verge-rev/clash-verge-rev", "tag": "v2.5.8", "cdn_dir": "clash-verge-rev/Clash%20Verge%20Rev%20v2.5.8", "file": "Clash.Verge_2.5.8_{arch}.deb", "port": 7897, "cmds": ["clash-verge", "clash-verge-rev"], + "deb": "clash-verge", + # WebKitGTK 的 DMABUF 渲染在 vmware/virtualbox 虚拟机显卡上会白屏, 关掉它改走更稳的渲染路径; + # 真机加上只是少用一个 GPU 优化, 无副作用, 所以默认都加(2026-11 在 136 VMware 实测有效) + "desktop_env": "WEBKIT_DISABLE_DMABUF_RENDERER=1", }, } @@ -66,7 +73,7 @@ def _out(self, result): def _choose_install_mode(self): mode_dic = {code: client["tip"] for code, client in CLIENTS.items()} - code, _ = ChooseTask(mode_dic, "请选择代理工具(不知道选哪个就选1):", False).run() + code, _ = ChooseTask(mode_dic, "请选择代理工具(不知道选哪个就选2 Clash Verge):", False).run() return code def _download_package(self, client, path): @@ -199,6 +206,24 @@ def _which(self, name): return str(self._out(result)[0]).strip() return None + def _patch_desktop_env(self, client): + """ + 给桌面启动项的 Exec 前加上 env 变量, 解决这些 GUI 在虚拟机显卡上白屏的问题. + 从应用菜单点图标启动时才会读 .desktop, 所以要改这里(终端直接运行命令的用户需要自己带变量). + 幂等: 已经加过就不再加 + """ + env = client.get("desktop_env") + deb = client.get("deb") + if not env or not deb: + return + # .desktop 文件名可能带空格(如 "Clash Verge.desktop"), 用 while read 逐行处理 + cmd = ( + "dpkg -L {deb} 2>/dev/null | grep -E '/applications/.*\\.desktop$' | while read -r f; do " + "grep -q 'env {env}' \"$f\" || sudo sed -i 's#^Exec=#Exec=env {env} #' \"$f\"; " + "done; sudo update-desktop-database >/dev/null 2>&1 || true" + ).format(deb=deb, env=env) + self._run_cmd(cmd, 20, "优化 {} 在虚拟机里的启动方式(避免界面白屏)...".format(client["name"])) + def _print_launch_tips(self, client): PrintUtils.print_delay("==========使用方法===========") cmd_path = None @@ -233,6 +258,7 @@ def install_proxy_tool(self): return False PrintUtils.print_success("{} 安装完成".format(client["name"])) + self._patch_desktop_env(client) subscribe_url = self._read_input_subscription() if subscribe_url: From 47ecdcb9729a794664a9c3ad2ffdd50d55799096 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:24:29 -0800 Subject: [PATCH 44/45] =?UTF-8?q?feat(ai-tools):=20[18]=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20Cockpit=20Tools;=20CC=20Switch=20=E4=B8=AD=E8=BD=AC?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=94=B9=E4=B8=BA=E5=AE=98=E7=BD=91=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 [18] 菜单里的 Cockpit Tools, 现为 6 个 AI 编程工具 - CC Switch 装完提示的中转模型供应商链接从官网后台 /admin/install/aff 读取(键 cc_switch_relay), 读不到用默认链接, 后台留空则不提示 Co-Authored-By: Claude Opus 4.8 --- tools/tool_install_opencode.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index b03c405..1df6487 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -4,10 +4,13 @@ import re from .base import BaseTool -from .base import PrintUtils, CmdTask, ChooseTask, DownloadUtils +from .base import PrintUtils, CmdTask, ChooseTask, DownloadUtils, AdUtils from .base import osarch from .tool_install_nodejs import Tool as NodejsTool +# CC Switch 装完后提示里的中转模型供应商注册链接; 可在官网后台隐藏页 /admin/install/aff 配置, 留空则不提示 +DEFAULT_CC_SWITCH_RELAY_URL = "https://www.0809.one/sign-up?aff=8Zdc" + # npm 安装的命令行工具. node 是需要的最低 Node.js 版本(取自各包 package.json 的 engines, 没写的按 20 算) NPM_APPS = { 1: {"name": "OpenCode", "tip": "OpenCode: 开源AI编程助手(命令行/网页)", @@ -46,12 +49,9 @@ "repo": "farion1231/cc-switch", "tag": "v4.0.24", "deb": "cc-switch", "file": "CC-Switch-{tag}-Linux-{arch}.deb", "arch": {"amd64": "x86_64", "arm64": "arm64"}, "usage": ["1.启动: 在应用菜单中搜索 CC Switch, 或终端运行 cc-switch", - "2.添加模型供应商(填 API 地址和 Key), 然后一键切换 Claude Code、Codex 等工具使用的供应商"]}, - 7: {"name": "Cockpit Tools", "tip": "Cockpit Tools: 图形界面, 管理多个AI编程工具的账号和额度", - "repo": "jlcodes99/cockpit-tools", "tag": "v1.3.78", "deb": "cockpit-tools", - "file": "Cockpit.Tools_{ver}_{arch}.deb", "arch": {"amd64": "amd64", "arm64": "arm64"}, - "usage": ["1.启动: 在应用菜单中搜索 Cockpit Tools", - "2.导入各个 AI 编程工具的账号后, 可以查看额度、一键切换账号"]}, + "2.添加相应工具的模型供应商即可使用{relay}"], + "relay": {"link_name": "cc_switch_relay", "default": DEFAULT_CC_SWITCH_RELAY_URL, + "text": "(如需中转模型供应商, 可点击 {} 获取)"}}, } @@ -513,7 +513,14 @@ def install_deb_app(self): return False PrintUtils.print_success("{}安装完成".format(app["name"])) PrintUtils.print_info("==========使用方法===========") - for line in app["usage"]: + usage = app["usage"] + relay = app.get("relay") + if relay: + # 中转供应商链接从官网后台读, 后台留空就不提示这一段 + url = AdUtils.link(relay["link_name"], relay["default"]) + suffix = relay["text"].format(url) if url else "" + usage = [line.replace("{relay}", suffix) for line in usage] + for line in usage: PrintUtils.print_info(line) return True From f07f46dc8b8526955a8bc92bd32ac79fe5660f83 Mon Sep 17 00:00:00 2001 From: sixiaopangai <149802344+sixiaopangai@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:32:26 -0800 Subject: [PATCH 45/45] =?UTF-8?q?fix(ai-tools):=20[18]=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=8F=8F=E8=BF=B0=E5=8E=BB=E6=8E=89=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E7=9A=84=20Cockpit=20Tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.py b/install.py index 5280fd3..3e2f6b5 100644 --- a/install.py +++ b/install.py @@ -135,7 +135,7 @@ "dep": [], }, 18: { - "tip": "一键安装/卸载:AI编程工具(OpenCode/Codex/Claude Code/DeepSeek/Pi/CC Switch/Cockpit Tools)", + "tip": "一键安装/卸载:AI编程工具(OpenCode/Codex/Claude Code/DeepSeek/Pi/CC Switch)", "type": CAT_DEV_TOOLS, "tool": "tools/tool_install_opencode.py", "dep": [6],